Arrays
vue-form renders any T[] field through the AsArray Tier-2 default. Add and remove are wired automatically, length constraints flow from @expect.minLength / @expect.maxLength, and the item-noun for the "Add" button is configurable.
Patterns at a glance
T[] → AsArray
T[]? → AsArray with "enable this section" affordance when undefinedFour shapes show up in practice:
- Optional scalar arrays —
tags?: string[]. Collapsed to an Add placeholder until the user starts. - Required scalar arrays —
headers: string[]. Starts with zero items; submit blocks on@expect.minLength 1. - Object arrays —
contacts?: { ... }[]. Each item renders as a card with its own Remove button. - Union-item arrays —
events: EventA | EventB[]. Adding picks a variant; switching wipes the item data.
Plus the composition pattern: nested arrays — team?: { name, members?: string[] }[].
The canonical example
A Team.as schema that covers every array shape in one form.
@meta.label 'Array Showcase'
@meta.description 'Every array variant in one form.'
@ui.form.submit.text 'Save'
export interface ArrayShowcaseForm {
// Optional array of primitives — collapsed to the Add placeholder
// when empty.
@meta.label 'Tags'
@ui.form.label.singular 'tag'
tags?: string[]
// Required array of primitives — opens with zero items and a
// footer Add button.
@meta.label 'HTTP headers'
@ui.form.label.singular 'header'
@expect.minLength 1, 'At least one header is required'
headers: string[]
// Optional array of objects — each item is a card.
@meta.label 'Contacts'
@ui.form.label.singular 'contact'
contacts?: {
@meta.label 'First name'
@meta.required 'First name is required'
@ui.form.grid.colSpan '6'
firstName: string
@meta.label 'Last name'
@ui.form.grid.colSpan '6'
lastName?: string
@meta.label 'Email'
@meta.required 'Email is required'
email: string.email
}[]
// Required array of objects — at least one entry.
@meta.label 'Phone numbers'
@ui.form.label.singular 'phone'
@expect.minLength 1, 'At least one phone is required'
phones: {
@meta.label 'Label'
@meta.required 'Label is required'
@ui.form.grid.colSpan '4'
label: string
@meta.label 'Number'
@meta.required 'Number is required'
@ui.form.grid.colSpan '8'
number: string
}[]
// Nested array.
@meta.label 'Team'
@ui.form.label.singular 'team'
team?: {
@meta.label 'Team name'
@meta.required 'Team name is required'
name: string
@meta.label 'Members'
@ui.form.label.singular 'member'
members?: string[]
}[]
}Mount the form the usual way and every array gets +/− buttons, count badges, and per-item validation for free:
<script setup lang="ts">
import { AsForm, createDefaultTypes, createAsFormDef } from "@atscript/vue-form";
import { ArrayShowcaseForm } from "./ArrayShowcase.as";
const types = createDefaultTypes();
const { def, formData } = createAsFormDef(ArrayShowcaseForm);
</script>
<template>
<AsForm :def="def" :form-data="formData" :types="types" @submit="onSave" />
</template>Add / Remove constraints
canAdd and canRemove are derived from the length constraints (packages/vue-form/src/composables/use-as-array.ts:92-98):
const canAdd = !disabled && length < maxLength;
const canRemove = !disabled && length > minLength;| Annotation | Effect |
|---|---|
@expect.minLength N, 'msg' | Add starts enabled; Remove hides once length === N |
@expect.maxLength N, 'msg' | Add hides once length === N |
@ui.form.disabled on the array prop | Both Add and Remove disable |
Validation runs at submit (and live, depending on firstValidation) — exceeding maxLength is gated by the UI, but minLength is enforced by both the UI (Remove disabled) and the submit-time validator.
The "Add ___" button text
@ui.form.label.singular 'tag' controls the noun used in the Add button: "Add tag", "Add tag", "Add tag". Without it the default is "item" — fine for prototypes, not for real UI.
Falls back to the item's prop name if not set on the array prop — useful for tuples and inline arrays. See packages/vue-form/src/components/as-field.vue:308-316.
Item identity
Each item gets a stable string key generated by useAsArray (as-item-0, as-item-1, …). The key persists across remounts as long as the item doesn't get re-added; removing item 2 from [a, b, c] leaves a and c with their original keys.
This is what makes per-item validation behave correctly — a fresh-just- added item stays fresh; an existing item that gets edited promotes out of freshness, no matter what its current array index is.
The key is exposed on useAsArray()'s itemKeys array if you write a custom array renderer.
Nested arrays
Nested arrays just work — AsArray recurses through AsField → AsArray. Each level provides its own PATH_PREFIX_KEY so child fields compute absolute paths automatically (team.0.members.2). Required/optional toggles cascade naturally: an optional outer array is collapsed until enabled; a required inner array under it starts with the inner constraint.
Union-item arrays
events: EventA | EventB[] renders the Add button as a multi-action menu (one Add per variant). Switching variants on an existing item wipes the item's data — see Unions for the underlying behaviour and how to preserve user input across switches.
Custom array renderer
If you want a fully custom array UI — a sortable list, a chip input, an SAP-like detail-master — call useAsArray(field) from a Tier-2 swap component:
<script setup lang="ts">
import { useAsArray, AsField, AsFieldShell, type TAsComponentProps } from "@atscript/vue-form";
import type { FormArrayFieldDef } from "@atscript/ui";
const props = defineProps<TAsComponentProps>();
const {
arrayValue,
itemKeys,
isUnion,
unionVariants,
isEmpty,
getItemField,
addItem,
removeItem,
canAdd,
canRemove,
} = useAsArray(props.field as FormArrayFieldDef);
</script>
<template>
<AsFieldShell v-bind="$props">
<template #default>
<div v-for="(_, idx) in arrayValue" :key="itemKeys[idx]">
<AsField :field="getItemField(idx)" />
<button v-if="canRemove" @click="removeItem(idx)">−</button>
</div>
<button v-if="canAdd" @click="addItem()">Add</button>
</template>
</AsFieldShell>
</template>Register it via the :components map and opt-in per field with @ui.form.component 'sortable-array'.
Phantom helpers
@ui.array.add.label 'X' (alias for @ui.form.label.singular) is still accepted for backwards compatibility — both produce the same button text. Prefer @ui.form.label.singular.
Next steps
- Nested Objects — combining arrays and objects.
- Unions — union-item arrays in depth.
- Validation — how
@expect.minLength/maxLengtherrors surface. - Custom Components — building a Tier-2 swap with
useAsArray.