Field Logic
Core concepts
In the previous page, we introduced the Core Concepts of Angular Signal Forms, including the basics of 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 property | Description |
|---|---|
| value | A signal containing the value of the current field. |
| state | The state of the current field. |
| field | The current field. |
| valueOf | Gets the value of the field represented by the given path. |
| stateOf | Gets the state of the field represented by the given path. |
| fieldTreeOf | Gets the field represented by the given path. |
| pathKeys | The list of keys that lead from the root field to the current field. |
| key | The key of the current field in its parent field. |
| index | The 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:

Form Inspector
Field Tree
(root) valid: true | invalid: false | pending: false
value: {
"disableForm": false,
"username": "My username"
}disabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
disableForm valid: true | invalid: false | pending: false
value: falsedisabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
— none —
username valid: true | invalid: false | pending: false
value: "My username"disabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
— none —
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 Function | Description |
|---|---|
| disabled | Binds logic that reactively determines whether the field is disabled. |
| hidden | Binds logic that reactively determines whether the field is hidden. |
| readonly | Binds logic that reactively determines whether the field is read-only. |
| debounce | Binds logic that delays the synchronization of values from the UI control to the model. |
| metadata | Binds logic that calculates and attaches custom metadata values to the field. |
| validate | Binds a synchronous validation function to the field. |
| validateTree | Binds a synchronous validation function that can apply errors to the field or its descendants. |
| validateAsync | Binds an asynchronous validation function (using a Resource) to the field. |
| validateHttp | Binds logic that triggers an HTTP request to validate the field. |
| required | Binds a validator that checks if the field has a non-empty value. |
| min | Binds logic that validates if a numeric value meets a minimum threshold. |
| max | Binds logic that validates if a numeric value meets a maximum threshold. |
| minLength | Binds logic that validates if a string or array meets a minimum length. |
| maxLength | Binds logic that validates if a string or array meets a maximum length. |
| pattern | Binds logic that validates if a string matches a specific regular expression. |
| Binds logic that validates if a string matches standard email formatting. | |
| validateStandardSchema | Binds 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 API | Description |
|---|---|
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.
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": ""
}
}
}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": ""
}
}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: ""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": ""
}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: ""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: ""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: ""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: ""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": ""
}
}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: ""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": ""
}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: ""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: ""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: ""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: ""disabledReasons: (0)
[]errors: (1)
[
{
"kind": "required",
"message": "Street is required"
}
]errorSummary: (1)
[
{
"kind": "required",
"message": "Street is required"
}
]meta: {
"pattern": [],
"required": true
}
Children
— none —
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.
Form Inspector
Field Tree
(root) valid: true | invalid: false | pending: false
value: {
"packages": []
}disabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
packages valid: true | invalid: false | pending: false
value: []disabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
— none —
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.
Form Inspector
Field Tree
(root) valid: true | invalid: false | pending: false
value: {
"shipmentType": "package",
"packages": [],
"documents": []
}disabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
shipmentType valid: true | invalid: false | pending: false
value: "package"disabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
— none —
packages valid: true | invalid: false | pending: false
value: []disabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
— none —
documents valid: true | invalid: false | pending: false
value: []disabledReasons: (0)
[]errors: (0)
[]errorSummary: (0)
[]meta: {
"pattern": [],
"required": false
}
Children
— none —
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 }]);
}
}