Field Logic

Field Logic

Core concepts

In the previous page, we introduced the Core Concepts of Angular Signal Forms, including the basics of Core Concepts [Field Logic]. If you haven't checked that section yet, it's recommended to do so before continuing. In this page, we take a deeper look at the Field Logic and explore its full range of features in detail.

Logic Functions

To add logic to a field, we use the schema function. Inside the schema definition, we can register multiple logic functions. These functions are evaluated together to compute the field’s derived state — for example whether it should be visible, required, or disabled.

Each logic function receives a FieldContext as input. The FieldContext is one of the most important tools when working with field logic. As you’ll see in later sections, it becomes very useful for things like building custom validators, performing cross-field validation, and managing dynamic behavior within the form.

FieldContext propertyDescription
valueA signal containing the value of the current field.
stateThe state of the current field.
fieldThe current field.
valueOfGets the value of the field represented by the given path.
stateOfGets the state of the field represented by the given path.
fieldTreeOfGets the field represented by the given path.
pathKeysThe list of keys that lead from the root field to the current field.
keyThe key of the current field in its parent field.
indexThe index of the current field in its parent array field.

Logic Binding Functions

To attach logic functions to a field, Angular provides several built-in logic binding functions (e.g. validate(), required(), disabled()). These binding functions allow you to register your logic functions declaratively and control the field’s behavior as part of the schema.

Here is an overview of how all these are connected:

Create form

Form Inspector

Field Tree

(root) valid: true | invalid: false | pending: false
value: {
  "disableForm": false,
  "username": "My username"
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

disableForm valid: true | invalid: false | pending: false
value: false
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

username valid: true | invalid: false | pending: false
value: "My username"
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

TypeScript
HTML
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { disabled, FormField, form, schema } from '@angular/forms/signals';
import { FormInspectorComponent } from '../../../ui/form-inspector.ts/form-inspector';
import { DemoLayout } from '../../../ui/demo-layout/demo-layout';

interface MyDataModel {
  disableForm: boolean;
  username: string;
}

@Component({
  selector: 'field-logic',
  templateUrl: './field-logic.html',
  imports: [FormField, FormInspectorComponent, DemoLayout],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FieldLogic {
  protected readonly myForm = form(
    signal<MyDataModel>({
      disableForm: false,
      username: 'My username',
    }),
    schema<MyDataModel>((schemaPath) => {
      disabled(schemaPath.username, ({ valueOf, stateOf, fieldTree, value, key, fieldTreeOf }) => {
        console.log('Field Context:', {
          valueOf: valueOf(schemaPath.disableForm),
          stateOf: stateOf(schemaPath.disableForm),
          fieldTree: fieldTree(),
          value: value(),
          key: key(),
          fieldTreeOf: fieldTreeOf(schemaPath.disableForm),
        });
        return valueOf(schemaPath.disableForm) ? 'Username cannot be changed' : false;
      });
    }),
  );
}

Here is a list of all the available built-in logic binding functions:

Logic Binding FunctionDescription
disabledBinds logic that reactively determines whether the field is disabled.
hiddenBinds logic that reactively determines whether the field is hidden.
readonlyBinds logic that reactively determines whether the field is read-only.
debounceBinds logic that delays the synchronization of values from the UI control to the model.
metadataBinds logic that calculates and attaches custom metadata values to the field.
validateBinds a synchronous validation function to the field.
validateTreeBinds a synchronous validation function that can apply errors to the field or its descendants.
validateAsyncBinds an asynchronous validation function (using a Resource) to the field.
validateHttpBinds logic that triggers an HTTP request to validate the field.
requiredBinds a validator that checks if the field has a non-empty value.
minBinds logic that validates if a numeric value meets a minimum threshold.
maxBinds logic that validates if a numeric value meets a maximum threshold.
minLengthBinds logic that validates if a string or array meets a minimum length.
maxLengthBinds logic that validates if a string or array meets a maximum length.
patternBinds logic that validates if a string matches a specific regular expression.
emailBinds logic that validates if a string matches standard email formatting.
validateStandardSchemaBinds a Standard Schema (e.g. Zod) validator to the field.

Schema composition

Imagine building a shipping form for a logistics platform (e.g. FedEx or UPS). While building this form, you would very quickly start to encounter a few challenges. The form would naturally grow very large, especially when handling international shipments, where many additional fields are required to comply with different regional regulations.

In such cases, it becomes important to split validation and logic rules into smaller, more readable schema blocks that can be composed together when building the form (for example, one schema definition for the contact details data structure and another for the package details data structure). Additionally, we often want to reuse the same schema for common form structures, such as recipient details and sender details.

Angular Signal Forms give us the tools to build complex validation and logic rules by composing smaller, reusable schema blocks. These tools are the following functions:

Schema composition APIDescription
apply()Apply a schema to a specific path
applyWhen()Conditionally apply a schema
applyWhenValue()Apply a schema when the value matches a condition
applyEach()Apply a schema to every item in an array or object

Apply a schema to a specific path - apply()

We can create reusable chunks of schema and bind them to a specific path within a parent schema definition. This is especially useful when parts of a form share the same data structure and logic.

For example, in the shipping form, both the sender and recipient address details follow the same structure. Instead of duplicating the scehma definition, we can define a reusable child schema for address details and then bind it to the corresponding path in the parent schema.

When doing so, the child schemas are merged into the parent form schema. This allows the parent schema to still define its own logic. For instance, in the following example, the parent schema defines a metadata logic function, while the address-specific logic is provided by the reusable child schema.

Sender
Recipient

Form Inspector

Field Tree

(root) valid: false | invalid: true | pending: false
value: {
  "sender": {
    "name": "",
    "address": {
      "country": "",
      "city": "",
      "postalCode": "",
      "street": ""
    }
  },
  "recipient": {
    "name": "",
    "address": {
      "country": "",
      "city": "",
      "postalCode": "",
      "street": ""
    }
  }
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (10)
[
  {
    "kind": "required",
    "message": "Name is required"
  },
  {
    "kind": "required",
    "message": "Country is required"
  },
  {
    "kind": "required",
    "message": "City is required"
  },
  {
    "kind": "required",
    "message": "Postal code is required"
  },
  {
    "kind": "required",
    "message": "Street is required"
  },
  {
    "kind": "required",
    "message": "Name is required"
  },
  {
    "kind": "required",
    "message": "Country is required"
  },
  {
    "kind": "required",
    "message": "City is required"
  },
  {
    "kind": "required",
    "message": "Postal code is required"
  },
  {
    "kind": "required",
    "message": "Street is required"
  }
]
meta: {
  "pattern": [],
  "required": false
}
            

Children

sender valid: false | invalid: true | pending: false
value: {
  "name": "",
  "address": {
    "country": "",
    "city": "",
    "postalCode": "",
    "street": ""
  }
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (5)
[
  {
    "kind": "required",
    "message": "Name is required"
  },
  {
    "kind": "required",
    "message": "Country is required"
  },
  {
    "kind": "required",
    "message": "City is required"
  },
  {
    "kind": "required",
    "message": "Postal code is required"
  },
  {
    "kind": "required",
    "message": "Street is required"
  }
]
meta: {
  "pattern": [],
  "required": false
}
            

Children

name valid: false | invalid: true | pending: false
value: ""
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (1)
[
  {
    "kind": "required",
    "message": "Name is required"
  }
]
errorSummary: (1)
[
  {
    "kind": "required",
    "message": "Name is required"
  }
]
meta: {
  "pattern": [],
  "required": true
}
            

Children

— none —

address valid: false | invalid: true | pending: false
value: {
  "country": "",
  "city": "",
  "postalCode": "",
  "street": ""
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (4)
[
  {
    "kind": "required",
    "message": "Country is required"
  },
  {
    "kind": "required",
    "message": "City is required"
  },
  {
    "kind": "required",
    "message": "Postal code is required"
  },
  {
    "kind": "required",
    "message": "Street is required"
  }
]
meta: {
  "pattern": [],
  "required": false
}
            

Children

country valid: false | invalid: true | pending: false
value: ""
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (1)
[
  {
    "kind": "required",
    "message": "Country is required"
  }
]
errorSummary: (1)
[
  {
    "kind": "required",
    "message": "Country is required"
  }
]
meta: {
  "pattern": [],
  "required": true
}
            

Children

— none —

city valid: false | invalid: true | pending: false
value: ""
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (1)
[
  {
    "kind": "required",
    "message": "City is required"
  }
]
errorSummary: (1)
[
  {
    "kind": "required",
    "message": "City is required"
  }
]
meta: {
  "pattern": [],
  "required": true
}
            

Children

— none —

postalCode valid: false | invalid: true | pending: false
value: ""
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (1)
[
  {
    "kind": "required",
    "message": "Postal code is required"
  }
]
errorSummary: (1)
[
  {
    "kind": "required",
    "message": "Postal code is required"
  }
]
meta: {
  "minLen": 5,
  "pattern": [],
  "required": true
}
            

Children

— none —

street valid: false | invalid: true | pending: false
value: ""
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (1)
[
  {
    "kind": "required",
    "message": "Street is required"
  }
]
errorSummary: (1)
[
  {
    "kind": "required",
    "message": "Street is required"
  }
]
meta: {
  "pattern": [],
  "required": true
}
            

Children

— none —

recipient valid: false | invalid: true | pending: false
value: {
  "name": "",
  "address": {
    "country": "",
    "city": "",
    "postalCode": "",
    "street": ""
  }
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (5)
[
  {
    "kind": "required",
    "message": "Name is required"
  },
  {
    "kind": "required",
    "message": "Country is required"
  },
  {
    "kind": "required",
    "message": "City is required"
  },
  {
    "kind": "required",
    "message": "Postal code is required"
  },
  {
    "kind": "required",
    "message": "Street is required"
  }
]
meta: {
  "pattern": [],
  "required": false
}
            

Children

name valid: false | invalid: true | pending: false
value: ""
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (1)
[
  {
    "kind": "required",
    "message": "Name is required"
  }
]
errorSummary: (1)
[
  {
    "kind": "required",
    "message": "Name is required"
  }
]
meta: {
  "pattern": [],
  "required": true
}
            

Children

— none —

address valid: false | invalid: true | pending: false
value: {
  "country": "",
  "city": "",
  "postalCode": "",
  "street": ""
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (4)
[
  {
    "kind": "required",
    "message": "Country is required"
  },
  {
    "kind": "required",
    "message": "City is required"
  },
  {
    "kind": "required",
    "message": "Postal code is required"
  },
  {
    "kind": "required",
    "message": "Street is required"
  }
]
meta: {
  "pattern": [],
  "required": false
}
            

Children

country valid: false | invalid: true | pending: false
value: ""
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (1)
[
  {
    "kind": "required",
    "message": "Country is required"
  }
]
errorSummary: (1)
[
  {
    "kind": "required",
    "message": "Country is required"
  }
]
meta: {
  "pattern": [],
  "required": true
}
            

Children

— none —

city valid: false | invalid: true | pending: false
value: ""
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (1)
[
  {
    "kind": "required",
    "message": "City is required"
  }
]
errorSummary: (1)
[
  {
    "kind": "required",
    "message": "City is required"
  }
]
meta: {
  "pattern": [],
  "required": true
}
            

Children

— none —

postalCode valid: false | invalid: true | pending: false
value: ""
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (1)
[
  {
    "kind": "required",
    "message": "Postal code is required"
  }
]
errorSummary: (1)
[
  {
    "kind": "required",
    "message": "Postal code is required"
  }
]
meta: {
  "minLen": 5,
  "pattern": [],
  "required": true
}
            

Children

— none —

street valid: false | invalid: true | pending: false
value: ""
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (1)
[
  {
    "kind": "required",
    "message": "Street is required"
  }
]
errorSummary: (1)
[
  {
    "kind": "required",
    "message": "Street is required"
  }
]
meta: {
  "pattern": [],
  "required": true
}
            

Children

— none —

TypeScript
HTML
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
  apply,
  createMetadataKey,
  FormField,
  form,
  metadata,
  minLength,
  required,
  schema,
} from '@angular/forms/signals';
import { FormInspectorComponent } from '../../../ui/form-inspector.ts/form-inspector';
import { DemoLayout } from '../../../ui/demo-layout/demo-layout';
import { FieldErrors } from '../../../ui/field-errors';

interface ShippingFormModel {
  sender: AddressContact;
  recipient: AddressContact;
}

export type AddressContact = {
  name: string;
  address: {
    country: string;
    city: string;
    postalCode: string;
    street: string;
  };
};

const addressContactSchema = schema<AddressContact>((addressPath) => {
  required(addressPath.name, { message: 'Name is required' });
  required(addressPath.address.country, { message: 'Country is required' });
  required(addressPath.address.city, { message: 'City is required' });
  required(addressPath.address.postalCode, { message: 'Postal code is required' });
  minLength(addressPath.address.postalCode, 5, {
    message: 'Postal code must be at least 5 characters',
  });
  required(addressPath.address.street, { message: 'Street is required' });
});

@Component({
  selector: 'apply-logic',
  templateUrl: './apply-logic.html',
  imports: [FormField, FormInspectorComponent, DemoLayout, FieldErrors],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ApplyLogic {
  protected readonly SAME_COUNTRY = createMetadataKey<boolean>();
  protected readonly shippingForm = form(
    signal<ShippingFormModel>({
      sender: {
        name: '',
        address: { country: '', city: '', postalCode: '', street: '' },
      },
      recipient: {
        name: '',
        address: { country: '', city: '', postalCode: '', street: '' },
      },
    }),
    schema<ShippingFormModel>((shippingPath) => {
      apply(shippingPath.sender, addressContactSchema);
      apply(shippingPath.recipient, addressContactSchema);
      metadata(shippingPath, this.SAME_COUNTRY, ({ valueOf }) => {
        const senderCountry = valueOf(shippingPath.sender.address.country);
        const recipientCountry = valueOf(shippingPath.recipient.address.country);
        return senderCountry === recipientCountry && senderCountry !== '';
      });
    }),
  );
}

Apply a schema to every item in an array or object - applyEach()

Imagine now that in the shipping form we have a dedicated section for package details. In this section, we can provide information such as the weight and the dimensions of each package. We can define the logic and validation rules for this part of the form in a child schema (packageSchema), which can then be reused for every package using the applyEach() API.

As you can see in the following example, the form supports adding multiple packages, and the same packageSchema definition is applied to each one. The rules that are currently applied for every package are the following: the description is required, the weight is required, the package must not weigh more than 20 kilos, and the user must provide all the package dimensions.

Packages

Form Inspector

Field Tree

(root) valid: true | invalid: false | pending: false
value: {
  "packages": []
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

packages valid: true | invalid: false | pending: false
value: []
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

TypeScript
HTML
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { applyEach, FormField, form, max, min, required, schema } from '@angular/forms/signals';
import { FormInspectorComponent } from '../../../ui/form-inspector.ts/form-inspector';
import { DemoLayout } from '../../../ui/demo-layout/demo-layout';
import { FieldErrors } from '../../../ui/field-errors';

interface ShippingFormModel {
  packages: Package[];
}

export type Package = {
  description: string;
  weight: number;
  dimensions: {
    length: number;
    width: number;
    height: number;
  };
};

const packageSchema = schema<Package>((packagePath) => {
  required(packagePath.description, { message: 'Description is required' });
  required(packagePath.weight, { message: 'Weight is required' });
  min(packagePath.weight, 0.1, { message: 'Weight must be at least 0.1 kg' });
  max(packagePath.weight, 30, { message: 'Weight must be maximum 30 kg' });
  required(packagePath.dimensions, { message: 'Size is required' });
});

@Component({
  selector: 'apply-each-logic',
  templateUrl: './apply-each-logic.html',
  imports: [FormField, FormInspectorComponent, DemoLayout, FieldErrors],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ApplyEachLogic {
  protected readonly shippingForm = form(
    signal<ShippingFormModel>({
      packages: [],
    }),
    schema<ShippingFormModel>((shippingPath) => {
      applyEach(shippingPath.packages, packageSchema);
    }),
  );

  addPackage() {
    this.shippingForm
      .packages()
      .value.update((packages) => [
        ...packages,
        { description: '', weight: 0, dimensions: { length: 0, width: 0, height: 0 } },
      ]);
  }
}

Conditionally apply a schema - applyWhen()

Imagine now that the user can choose to ship either documents or packages. When documents are selected, the logic rules and validations related to dimensions should not be applied. In addition, the maximum allowed weight should be lower, for example 2 kg instead of the 30 kg limit used for regular packages.

In the following example, if the user first selects packages and adds a package without fixing the validation errors, and then switches the shipment type to documents, you will notice an important behavior: after adding a document and fixing its validation errors, the form reports no validation errors, even though the previously added package is still incomplete.

This happens because the packageSchema is no longer applied when the shipment type is document, due to the applyWhen predicate. Instead, only the documentSchema is active.

By conditionally applying schemas with applyWhen, we can clearly separate the logic and validation rules for documents and packages, ensuring that only the relevant rules are active based on the user’s selection.

Select the type of shipment:
Packages

Form Inspector

Field Tree

(root) valid: true | invalid: false | pending: false
value: {
  "shipmentType": "package",
  "packages": [],
  "documents": []
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

shipmentType valid: true | invalid: false | pending: false
value: "package"
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

packages valid: true | invalid: false | pending: false
value: []
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

documents valid: true | invalid: false | pending: false
value: []
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

TypeScript
HTML
import { ChangeDetectionStrategy, Component, effect, signal } from '@angular/core';
import {
  applyEach,
  applyWhen,
  FormField,
  form,
  max,
  min,
  required,
  schema,
} from '@angular/forms/signals';
import { FormInspectorComponent } from '../../../ui/form-inspector.ts/form-inspector';
import { DemoLayout } from '../../../ui/demo-layout/demo-layout';

interface ShippingFormModel {
  shipmentType: 'document' | 'package';
  packages: Package[];
  documents: Document[];
}

export type Package = {
  description: string;
  weight: number;
  dimensions: {
    length: number;
    width: number;
    height: number;
  };
};

export type Document = {
  description: string;
  weight: number;
};

const packageSchema = schema<Package>((packagePath) => {
  required(packagePath.description, { message: 'Description is required' });
  required(packagePath.weight, { message: 'Weight is required' });
  min(packagePath.weight, 0.1, { message: 'Weight must be at least 0.1 kg' });
  max(packagePath.weight, 30, { message: 'Weight must be maximum 30 kg' });
  required(packagePath.dimensions, { message: 'Size is required' });
});

const documentSchema = schema<Document>((documentPath) => {
  required(documentPath.weight, { message: 'Weight is required' });
  min(documentPath.weight, 0.1, { message: 'Weight must be at least 0.1 kg' });
  max(documentPath.weight, 2, { message: 'Weight must be maximum 2 kg' });
});

@Component({
  selector: 'apply-when-logic',
  templateUrl: './apply-when-logic.html',
  imports: [FormField, FormInspectorComponent, DemoLayout],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ApplyWhenLogic {
  protected readonly shippingForm = form(
    signal<ShippingFormModel>({
      shipmentType: 'package',
      packages: [],
      documents: [],
    }),
    schema<ShippingFormModel>((shippingPath) => {
      applyWhen(
        shippingPath.packages,
        () => this.shippingForm.shipmentType().value() === 'package',
        (packages) => {
          applyEach(packages, packageSchema);
        },
      );
      applyWhen(
        shippingPath.documents,
        () => this.shippingForm.shipmentType().value() === 'document',
        (documents) => {
          applyEach(documents, documentSchema);
        },
      );
    }),
  );

  addPackage() {
    this.shippingForm
      .packages()
      .value.update((packages) => [
        ...packages,
        { description: '', weight: 0, dimensions: { length: 0, width: 0, height: 0 } },
      ]);
  }

  addDocument() {
    this.shippingForm
      .documents()
      .value.update((documents) => [...documents, { description: '', weight: 0 }]);
  }
}

Async validation 🚧