Arrays create dynamic collections of field values. Each item in the fields array defines one array item with its structure and initial values.

For most use cases, the simplified array API is recommended. See Form Arrays (Simplified) for details. This page documents the complete API for advanced use cases.

Interactive Demo

Bootstrap Bootstrap
Loading live example

Structure Overview

The fields property supports two item formats:

  • Single FieldDef (not wrapped in an array) creates a primitive item: the field value is extracted directly
  • Array of FieldDefs creates an object item: the fields are merged into an object
// Primitive array: ['angular', 'typescript']
{
  key: 'tags',
  type: 'array',
  fields: [
    { key: 'tag', type: 'input', value: 'angular' },      // Single field = primitive
    { key: 'tag', type: 'input', value: 'typescript' },
  ]
}

// Object array: [{ name: 'Alice', email: '...' }]
{
  key: 'contacts',
  type: 'array',
  fields: [
    [                                                      // Array of fields = object
      { key: 'name', type: 'input', label: 'Name', value: 'Alice' },
      { key: 'email', type: 'input', label: 'Email', value: 'alice@example.com' }
    ],
  ]
}

Empty Arrays (No Initial Items)

For arrays that start empty and are populated via buttons:

{
  key: 'tags',
  type: 'array',
  fields: []  // No initial items - user adds via button
}

Array Size Validation

Use minLength and maxLength on the array field to constrain the number of items. When violated, the form becomes invalid (e.g. submit button is disabled), but no field-level error message is shown.

{
  key: 'tags',
  type: 'array',
  minLength: 1,  // At least 1 item required
  maxLength: 5,  // No more than 5 items
  fields: [
    [{ key: 'tag', type: 'input', label: 'Tag', value: 'default' }]
  ]
}
Property Type Description
minLength number Minimum number of array items
maxLength number Maximum number of array items

Both properties are optional and can be used independently or together.

Required Items

Setting required on the array marks every field inside each item as required, without repeating it on each template field:

{
  key: 'contacts',
  type: 'array',
  required: true,
  fields: [[
    { key: 'name', type: 'input', label: 'Name' },
    { key: 'email', type: 'input', label: 'Email' },
    { key: 'note', type: 'input', label: 'Note', required: false },
  ]],
}

A template field that declares its own required wins, so note above stays optional. A field whose requiredness is conditional (logic: [{ type: 'required', when }]) also keeps its own condition rather than being forced by the cascade.

This is about item contents; for "the array must have at least one item", use minLength: 1. It applies to object items only. Primitive-item arrays (a single field per item, producing ['a', 'b']) hold plain values with no field to mark, so required on the array has no effect there.

Array-Level Validation

Size bounds only count items. For a rule about the item contents — most commonly a per-row rule whose two fields have to be compared against each other — declare validators on the array. ctx.value() resolves to the item list:

{
  key: 'periods',
  type: 'array',
  minLength: 1,
  fields: [
    [
      { key: 'from', type: 'input', label: 'From', props: { type: 'datetime-local' }, value: '' },
      { key: 'to', type: 'input', label: 'To', props: { type: 'datetime-local' }, value: '' },
    ],
  ],
  validators: [{ type: 'custom', functionName: 'periodOrder' }],
  validationMessages: { periodOrder: 'Every period must end after it starts.' },
}

The registered function sees every row at once, so it can compare fields within a row:

const periodOrder: CustomValidator = (ctx) => {
  const rows = (ctx.value() as { from?: string; to?: string }[]) ?? [];
  return rows.some((r) => r.from && r.to && r.to < r.from) ? { kind: 'periodOrder' } : null;
};

This is the only way to express a rule that spans two fields of the same row. A validator placed on the to template field cannot see its sibling from, because sibling access needs schema paths that only exist while the schema is being built.

Pointing at the offending row

One message for the whole list cannot say which row is wrong. rowError targets the error at one row's field, where the adapter renders it like any other field error:

import { rowError, type CustomValidator } from '@ng-forge/dynamic-forms';

const periodOrder: CustomValidator = (ctx) => {
  const rows = (ctx.value() ?? []) as { from?: string; to?: string }[];

  return rows.flatMap((row, index) =>
    row.from && row.to && row.to < row.from
      ? [rowError(ctx, index, 'to', { kind: 'periodOrder', message: 'The end must not be before the start.' })]
      : [],
  );
};

A targeted error needs its own message: the container's validationMessages are keyed to the container, not to the child the error moves to. If the row or field cannot be resolved, the error stays on the container rather than disappearing.

Errors returned without rowError stay on the container and use validationMessages as usual.

The error lands on the array itself, so it gates form and page validity. Array validators are skipped while the array is hidden, unless you set validateWhenHidden: true. The same properties work on the simplified array API.

Rendering the message

Like a group, an array has no form element to hang a message on. Declaring validators on the array makes ng-forge append the built-in field-errors wrapper, which renders the first resolved message below the array's items:

<div class="df-field-error" role="alert">Every period must end after it starts.</div>

Each UI adapter ships its own version of this wrapper, so the message renders in the adapter's native error style out of the box. To restyle it, register your own wrapper under the same field-errors name — the later registration replaces the built-in everywhere. See Group-Level Validation for the full override example, and Registering and Applying for the wrapper pipeline.

Initial Values

Initial values are defined directly on each field via the value property - no separate initialValue is needed:

{
  key: 'emails',
  type: 'array',
  fields: [
    // One initial item with pre-filled value (object item)
    [
      { key: 'email', type: 'input', label: 'Email', value: 'primary@example.com' }
    ]
  ]
}

Primitive Arrays (Simple Value Lists)

For simple arrays of primitive values like ['tag1', 'tag2'], use a single FieldDef per item (not wrapped in an array):

{
  key: 'tags',
  type: 'array',
  fields: [
    { key: 'tag', type: 'input', label: 'Tag', value: 'featured' },
    { key: 'tag', type: 'input', label: 'Tag', value: 'popular' }
  ]
}

This creates a true primitive array in the form value:

{
  tags: ['featured', 'popular'];
}

The key property on each field is used for internal tracking but doesn't affect the output value.

Object Arrays (Multiple Fields per Item)

For arrays of objects with multiple fields, wrap the fields in an inner array:

{
  key: 'contacts',
  type: 'array',
  fields: [
    [
      { key: 'name', type: 'input', label: 'Name', value: '' },
      { key: 'phone', type: 'input', label: 'Phone', value: '' }
    ]
  ]
}

This creates an array of objects:

{
  contacts: [{ name: '', phone: '' }];
}

Nested Object Arrays (Using Groups)

For arrays with nested object structure, use a group field:

{
  key: 'contacts',
  type: 'array',
  fields: [
    [
      {
        key: 'contact',
        type: 'group',
        fields: [
          { key: 'name', type: 'input', label: 'Name', value: '' },
          { key: 'phone', type: 'input', label: 'Phone', value: '' }
        ]
      }
    ]
  ]
}

This creates an array of nested objects (note the group key creates the nesting):

{
  contacts: [{ contact: { name: '', phone: '' } }];
}

Heterogeneous Arrays (Mixed Primitives and Objects)

Arrays can contain both primitive and object items in the same array:

{
  key: 'items',
  type: 'array',
  fields: [
    [{ key: 'label', type: 'input', value: 'Structured' }],  // Object item (wrapped in array)
    { key: 'value', type: 'input', value: 'Simple' },        // Primitive item (single field)
  ]
}

This creates a heterogeneous array:

{
  items: [{ label: 'Structured' }, 'Simple'];
}

Array vs Group

  • Groups create nested objects with keys: { address: { street: '', city: '' } }
  • Primitive Arrays create lists of values: { tags: ['value1', 'value2'] }
  • Object Arrays create lists of objects: { items: [{name: ''}, {name: ''}] }

Dynamic Add/Remove

Use button field types directly in your form configuration for declarative array manipulation:

Button Type Placement Description
add-array-item Outside array Appends a new item to the end of the array
prepend-array-item Outside array Inserts a new item at the beginning of the array
insert-array-item Outside array Inserts a new item at a specific index
remove-array-item Inside each item Removes the current item from the array
pop-array-item Outside array Removes the last item from the array
shift-array-item Outside array Removes the first item from the array

Important: Add/prepend/insert buttons require a template property defining the new item structure. There is no fallback to the array's fields[0] - each button must explicitly define what structure to add.

The template can be:

  • Single FieldDef - creates a primitive item (field value is extracted directly)
  • Array of FieldDefs - creates an object item (fields merged into object)
// Template for PRIMITIVE items (single field, not wrapped)
const tagTemplate = { key: 'tag', type: 'input', label: 'Tag' };

// Template for OBJECT items (array of fields)
const contactTemplate = [
  { key: 'name', type: 'input', label: 'Name' },
  { key: 'phone', type: 'input', label: 'Phone' },
  // Remove button inside each item (no template needed)
  { key: 'remove', type: 'remove-array-item', label: 'Remove' },
];

// Form configuration
{
  fields: [
    {
      key: 'tags',
      type: 'array',
      fields: [], // Start empty - primitive array
    },
    // Add button for primitive items
    {
      key: 'addTag',
      type: 'add-array-item',
      label: 'Add Tag',
      arrayKey: 'tags',
      template: tagTemplate, // Single field = primitive item
    },
    {
      key: 'contacts',
      type: 'array',
      fields: [], // Start empty - object array
    },
    // Add button for object items
    {
      key: 'addContact',
      type: 'add-array-item',
      label: 'Add Contact',
      arrayKey: 'contacts',
      template: contactTemplate, // Array of fields = object item
    },
  ];
}

Programmatic Approach

For more control, use the arrayEvent builder to dispatch array operations directly. Note: When using the programmatic approach, you must provide a template.

Which API to use depends on where your code lives:

  • Inside a custom field component: inject EventBus (it is scoped to the form's DI tree)
  • From a host/parent component: provide and inject EventDispatcher (see Events)

From a host component (most common case):

import { Component, inject, signal } from '@angular/core';
import { DynamicForm, EventDispatcher, arrayEvent } from '@ng-forge/dynamic-forms';

@Component({
  providers: [EventDispatcher],
  imports: [DynamicForm],
  template: `<form [dynamic-form]="config" [(value)]="formValue"></form>`,
})
export class MyFormComponent {
  readonly formValue = signal<Record<string, unknown>>({});
  private readonly dispatcher = inject(EventDispatcher);

  // Template for PRIMITIVE items (single field, not wrapped)
  tagTemplate = { key: 'tag', type: 'input', label: 'Tag', value: '' };

  // Template for OBJECT items (array of fields)
  contactTemplate = [
    { key: 'name', type: 'input', label: 'Name', value: '' },
    { key: 'phone', type: 'input', label: 'Phone', value: '' },
  ];

  addTag() {
    this.dispatcher.dispatch(arrayEvent('tags').append(this.tagTemplate));
  }

  addContact() {
    this.dispatcher.dispatch(arrayEvent('contacts').append(this.contactTemplate));
  }

  prependItem() {
    this.dispatcher.dispatch(arrayEvent('contacts').prepend(this.contactTemplate));
  }

  addItemAt(index: number) {
    this.dispatcher.dispatch(arrayEvent('contacts').insertAt(index, this.contactTemplate));
  }

  removeLastItem() {
    this.dispatcher.dispatch(arrayEvent('contacts').pop());
  }

  removeFirstItem() {
    this.dispatcher.dispatch(arrayEvent('contacts').shift());
  }

  removeItemAt(index: number) {
    this.dispatcher.dispatch(arrayEvent('contacts').removeAt(index));
  }

  moveItem(from: number, to: number) {
    this.dispatcher.dispatch(arrayEvent('contacts').move(from, to));
  }
}

From inside a custom field component:

import { inject } from '@angular/core';
import { arrayEvent } from '@ng-forge/dynamic-forms';
import { EventBus } from '@ng-forge/dynamic-forms/integration';

export class MyFieldComponent {
  private readonly eventBus = inject(EventBus);

  addItem() {
    this.eventBus.dispatch(arrayEvent('contacts').append(this.contactTemplate));
  }
}

Use Cases

Arrays are ideal for:

  • Lists of simple values (tags, categories, keywords)
  • Repeating form sections (multiple addresses, phone numbers)
  • Dynamic collections where items can be added/removed
  • Collection-based data structures where order matters

Complete Example: Object Array with Row Items

Here's a complete working example of a tag list with initial values and declarative add/remove buttons. Each item is a row wrapping an input and a remove button; rows flatten, so each item produces an object like { value: 'featured' }:

import { FormConfig } from '@ng-forge/dynamic-forms';

// Template for new tags - a row with an input and a remove button
const tagTemplate = {
  key: 'tag',
  type: 'row',
  fields: [
    {
      key: 'value',
      type: 'input',
      label: 'Tag',
      required: true,
      minLength: 2,
    },
    {
      key: 'removeTag',
      type: 'remove-array-item',
      label: 'Remove',
      props: { color: 'warn' },
    },
  ],
} as const;

const formConfig = {
  fields: [
    {
      key: 'tags',
      type: 'array',
      // Start with two initial tags using the same row structure
      fields: [
        [
          {
            key: 'tag',
            type: 'row',
            fields: [
              { key: 'value', type: 'input', label: 'Tag', value: 'featured', required: true, minLength: 2 },
              { key: 'removeTag', type: 'remove-array-item', label: 'Remove', props: { color: 'warn' } },
            ],
          },
        ],
        [
          {
            key: 'tag',
            type: 'row',
            fields: [
              { key: 'value', type: 'input', label: 'Tag', value: 'popular', required: true, minLength: 2 },
              { key: 'removeTag', type: 'remove-array-item', label: 'Remove', props: { color: 'warn' } },
            ],
          },
        ],
      ],
    },
    // Declarative add button - no EventBus needed
    {
      key: 'addTag',
      type: 'add-array-item',
      label: 'Add Tag',
      arrayKey: 'tags',
      template: [tagTemplate],
      props: { color: 'primary' },
    },
  ],
} as const satisfies FormConfig;

// Form value: { tags: [{ value: 'featured' }, { value: 'popular' }] }

Complete Example: Object Array with Initial Values

Here's a complete working example of an object array field with initial values, validation, and declarative buttons:

import { FormConfig } from '@ng-forge/dynamic-forms';

// Template for new contacts - includes a remove button inside each item
const contactTemplate = [
  { key: 'name', type: 'input', label: 'Contact Name', required: true, minLength: 2 },
  { key: 'phone', type: 'input', label: 'Phone Number', required: true, pattern: /^\d{10}$/ },
  {
    key: 'relationship',
    type: 'select',
    label: 'Relationship',
    options: [
      { label: 'Family', value: 'family' },
      { label: 'Friend', value: 'friend' },
      { label: 'Colleague', value: 'colleague' },
    ],
  },
  {
    key: 'removeContact',
    type: 'remove-array-item',
    label: 'Remove Contact',
    props: { color: 'warn' },
  },
] as const;

const formConfig = {
  fields: [
    {
      key: 'contacts',
      type: 'array',
      // Start with one pre-filled contact (includes remove button)
      fields: [
        [
          { key: 'name', type: 'input', label: 'Contact Name', value: 'John Doe', required: true, minLength: 2 },
          {
            key: 'phone',
            type: 'input',
            label: 'Phone Number',
            value: '5551234567',
            required: true,
            pattern: /^\d{10}$/,
          },
          {
            key: 'relationship',
            type: 'select',
            label: 'Relationship',
            value: 'family',
            options: [
              { label: 'Family', value: 'family' },
              { label: 'Friend', value: 'friend' },
              { label: 'Colleague', value: 'colleague' },
            ],
          },
          {
            key: 'removeContact',
            type: 'remove-array-item',
            label: 'Remove Contact',
            props: { color: 'warn' },
          },
        ],
      ],
    },
    // Declarative add button - no EventBus needed
    {
      key: 'addContact',
      type: 'add-array-item',
      label: 'Add Contact',
      arrayKey: 'contacts',
      template: contactTemplate,
      props: { color: 'primary' },
    },
  ],
} as const satisfies FormConfig;

// Form value: { contacts: [{ name: 'John Doe', phone: '5551234567', relationship: 'family' }] }

Template Requirement

Important: When adding items dynamically (via buttons or event bus), you must always provide an explicit template. There is no automatic fallback to use the first item's structure.

This design ensures:

  • Clear intent - each add operation explicitly defines what to add
  • Flexibility - different buttons can add different item structures
  • No ambiguity - the array's fields only defines initial items, not a "default template"

Heterogeneous Items

Arrays can have items with different field structures:

{
  key: 'entries',
  type: 'array',
  fields: [
    // Item 0: Simple tag
    [{ key: 'tag', type: 'input', label: 'Tag', value: 'simple' }],
    // Item 1: Contact with more fields
    [
      { key: 'name', type: 'input', label: 'Name', value: 'Alice' },
      { key: 'email', type: 'input', label: 'Email', value: 'alice@example.com' }
    ]
  ]
}

This flexibility allows each item to have its own structure, though typically all items share the same structure for consistency.

Nesting Constraints

Array fields can be used within:

  • Pages (top-level container)
  • Rows (for horizontal layouts)
  • Groups (for nested arrays within objects)

Arrays cannot contain:

  • Other array fields (no nested arrays)
  • Page fields

Allowed Children

Arrays can contain these field types:

  • Leaf fields (input, select, checkbox, etc.), which create flat or object arrays
  • Group fields with a key, which create nested object arrays [{groupKey: {...}}, ...]
  • Row fields, which create flat object arrays [{...}, {...}] (rows don't add nesting)
  • Button fields (for remove operations inside each item)

See Type Safety & Inference for details on how arrays affect type inference.

Conditional Visibility

Array containers support the logic property to conditionally show or hide the entire array section based on form state.

{
  key: 'dependents',
  type: 'array',
  logic: [{
    type: 'hidden',
    condition: {
      type: 'fieldValue',
      fieldPath: 'hasDependents',
      operator: 'equals',
      value: false,
    },
  }],
  fields: [/* ... */],
}

When the array is hidden, all items and add/remove buttons are hidden with it. Only 'hidden' is supported as a logic type on containers.

For all available condition types and operators, see Conditional Logic.

When to Use the Complete API

The complete API documented on this page is the right choice when you need capabilities beyond what the simplified API offers:

  • Heterogeneous items -- Different field structures per item (e.g. some items have 2 fields, others have 5)
  • Custom button placement -- Positioning add/remove/prepend/insert buttons in specific locations relative to the array
  • Programmatic control via EventBus -- Dispatching arrayEvent commands for append, prepend, insertAt, pop, shift, and removeAt operations from component code
  • Custom templates per button action -- Different add buttons that each insert a different item structure (e.g. an "Add Simple" and an "Add Detailed" button)
  • Mixed primitive and object items -- Arrays containing both primitive values and object values in the same collection

For all other scenarios, the simplified array API provides a more concise configuration with auto-generated buttons.