The long-awaited Signal Forms bridge a crucial gap between Angular's Signal-based reactivity and user interaction. While currently experimental and intended to gather initial feedback, they show the direction Angular is heading.
In this article, I'll go into the details of this new library. In addition to the basics, I'll discuss more advanced aspects such as the many types of custom validators, conditional validation, subforms, and custom controls.
Table of Contents
- Creating a Signal Form
- Understanding the FieldTree Type
- Signal Form Schemas
- Field Validation: Custom, Conditional, Cross-Field, Async
- Nested Forms and Form Arrays
- Form Metadata
- Null and Undefined Values
- Creating Custom Fields
Creating a Signal Form
To get started, we create a first form for the flight itself. In later sections, we will extend it to include nested forms for the connected airplane and the prices:

Setting up the Component
Our implementation receives the flight to be displayed from a service named FlightDetailStore. To set up our Signal Form, we pass this flight to the form function provided by Angular's Signal Forms package :
// src/app/domains/ticketing/feature-booking/flight-edit/flight-edit.ts
import { linkedSignal } from '@angular/core';
import { form, minLength, required } from '@angular/forms/signals';
[...]
@Component([...])
export class FlightEditComponent {
private readonly store = inject(FlightDetailStore);
protected readonly flight = linkedSignal(() => this.store.flightValue());
// Set up the Signal Form with validation rules
protected readonly flightForm = form(this.flight, (path) => {
required(path.from);
required(path.to);
required(path.date);
minLength(path.from, 3);
});
[...]
}
As the service returns a readonly signal, we use linkedSignal to convert it in a writable one. This is important because Signal Forms writes back the modifications performed by the user.
The second parameter form gets passed is a schema with validation rules. Angular provides the required and minLength rules out of the box. Further simple validators that ship with Signal Forms include maxLength, min, max, and pattern. The latter checks against a regular expression.
The path parameter of type SchemaPathTree is used to point to the individual properties we want to validate, e.g., from or to.
As a result, form returns a FieldTree that allows to bind the individual properties of the flight to form controls in the template. We'll discuss this imporant data structure in the next section.
Understanding the FieldTree Type
You can think about a FieldTree as a deeply nested Signal. Each property in the data structure is represented by a Signal with form state, e.g. value, dirty, invalid. Because these properties are Signal-based, we can bind them to form controls.
For instance, let's imagine the flight we pass to the function form looks like this:
{
id: 1,
from: 'Graz',
to: 'Hamburg',
date: '2030-12-24T17:30',
delayed: false,
delay: 0,
aircraft: {
type: 'T0815',
registration: 'R4711'
},
prices: [
{ flightClass: 'economy', amount: 299 },
{ flightClass: 'business', amount: 599 },
]
}
In this case, the FieldTree returned by the form provides Signals for each property:
const date = flightForm.date().value();
const isDateDirty = flightForm.date().dirty();
const isDateInvalid = flightForm.date().invalid();
const dateErrors = flightForm.date().errors();
Here, the date itself is a signal and value, dirty, invalid, and errors are as well. The property dirty is true when the user modified the value and invalid is true when there was a validation error.
The errors property contains an array with all detected validation errors. For instance, our schema set up in the previous section requires from to be set. If this is not the case, this violation is reported by an object in the errors array. To get a better feeling for it, we'll display this array with the JsonPipe.
As the FieldTree is nested, we can also access the properties at deeper levels, such as the airplane's type or the prices:
const aircraftType = flightForm.aircraft.type().value();
const isAircraftTypeDirty = flightForm.aircraft.type().dirty();
const isAircraftTypeInvalid = flightForm.aircraft.type().invalid();
const aircraftTypeErrors = flightForm.aircraft.type().errors();
const firstPriceAmount = this.flightForm.prices[0].amount().value();
const isFirstPriceAmountDirty = this.flightForm.prices[0].amount().dirty();
const isFirstPriceAmountInvalid = this.flightForm.prices[0].amount().invalid();
const firstPriceAmountErrors = this.flightForm.prices[0].amount().errors();
For our example that means that we can bind the properties of the flight itself but also the properties of the connected airplane and the connected prices. The next section is already binding some properties to the template.
Binding to the Template
For binding parts of the Signal Form to the template, we need to import the FormField directive. For easily displaying validation errors during our first steps, we also import the JsonPipe:
// src/app/domains/ticketing/feature-booking/flight-edit/flight-edit.ts
[...]
import { JsonPipe } from '@angular/common';
import {
form,
FormField,
minLength,
required,
} from '@angular/forms/signals';
@Component({
selector: 'app-flight-edit',
imports: [
[...]
// Import FormField directive
FormField,
// Add JsonPipe
JsonPipe,
],
templateUrl: './flight-edit.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FlightEditComponent {
[...]
}
This directive can be used to bind individual fields of our FieldTree to form controls such as input or select but also to custom controls implemented with Angular as we will see at the end of this article.
For now, let's just bind some properties of our flight to input fields:
<!-- .../ticketing/feature-booking/flight-edit/flight-form/flight-form.html -->
@let flightForm = flight();
<fieldset>
<legend>Flight</legend>
<div class="form-group">
<label for="flight-id">ID</label>
<input
class="form-control"
[formField]="flightForm.id"
type="number"
id="flight-id"
/>
</div>
<div class="form-group">
<label for="flight-from">From</label>
<input
class="form-control"
[formField]="flightForm.from"
id="flight-from"
/>
@if (flightForm.id().invalid()) {
<div>{{ flightForm.id().errors() | json }}</div>
}
</div>
[…]
</fieldset>
Please note that when binding a number you need to use an <input type="number" ...> because Signal Forms also respects HTML semantics when ensuring type safety.
Behind errors is an array containing all validation errors detected for the field, which the component outputs for illustrative purposes. The following screenshot shows this array in a case where the minLength validator was triggered:

However, the minLength validator isn't triggered for empty fields. This common approach supports optional fields. However, if the field isn't optional, a required validator handles the empty field.
Submitting
In general, you don't need any special function to submit a form. You can simply read the form's value and pass it to a service or store. However, the submit function provided by Signal Forms offers additional benefits:
// src/app/domains/ticketing/feature-booking/flight-edit/flight-edit.ts
[...]
// Add submit
import {
form,
FormField,
minLength,
required,
submit,
} from '@angular/forms/signals';
[...]
@Component({
[...]
})
export class FlightEditComponent {
[...]
protected readonly flightForm = form(this.flight, [...]);
[...]
protected async save(): Promise<void> {
await submit(this.flightForm, async (form) => {
try {
await this.store.saveFlight(form().value());
return null;
} catch (error) {
return {
kind: "processing_error",
error: String(error),
};
}
});
}
}
The submit function accepts both a signal form and an action to be executed. The latter is only executed if there are no validation errors. If an error occurs during saving, this action can return a ValidationError. Alternatively, it could return an array containing multiple ValidationError objects.
The function submit adds these errors in the Signal Form. Hence, they can be displayed like any other validation errors by presenting the form element's errors array.
Modern Angular
This article is extracted from my new book Modern Angular - Architecture, Concepts, Implementation. This book covers everything you need for building modern business applications with Angular: from Signals and state patterns to architecture, AI assistants, testing, and practical solutions for real-world projects.
Schemas — How They Work?
The schema passed to the form function defines not only validation rules but also other aspects of the form's behavior. For instance, a schema could specify that changes are debounced or that some fields are read-only under certain conditions. In this section, I'll discuss these aspects in more detail.
Splitting Schemas for Maintainability
So far, the schema has been defined directly in the call to the form function. However, for larger schemas this approach quickly becomes unwieldy. To make the individual components clearer, the schema can be refactored to a separate constant in a separate file. Since the schema defines general rules, it can be placed in the data folder.
// src/app/domains/ticketing/data/flight-schema.ts
import { required, minLength, schema } from "@angular/forms/signals";
import { Flight } from "./flight";
export const flightSchema = schema<Flight>((path) => {
required(path.from);
required(path.to);
required(path.date);
minLength(path.from, 3);
});
The call to the function form can then refer to it:
// src/app/domains/ticketing/feature-booking/flight-edit/flight-edit.ts
[...]
protected readonly flightForm = form(this.flight, flightSchema);
[...]
Schemas can also reference each other. This is useful, for example, if a form adds some specific validation rules to a schema with general ones:
import {
apply,
minLength,
required,
schema,
} from '@angular/forms/signals';
import { flightSchema } from '../../data/flight-schema';
export const flightFormSchema = schema<Flight>((path) => {
apply(path, flightSchema);
required(path.id);
});
Another case where schemas refer to other schemas is conditional validation rules. Below, we will discuss this.
Controlling Form Behavior
Schemas not only define validation rules but also further behavior. For instance, the schema can also specify under which circumstances the formField directive should disable a field:
// src/app/domains/ticketing/data/flight-schema.ts
import { disabled } from '@angular/forms/signals';
[...]
disabled(path.delay, (ctx) => !ctx.valueOf(path.delayed));
[...]
Instead of true, you can also return a reason for the disabling:
// src/app/domains/ticketing/data/flight-schema.ts
disabled(path.delay, (ctx) =>
!ctx.valueOf(path.delayed) ? "not delayed" : false,
);
All the reasons why a field is currently disabled are found in its disabledReasons Signal:
<!-- src/app/domains/ticketing/feature-booking/flight-edit/flight-form/flight-form.html -->
@for (reason of flightForm.delay().disabledReasons(); track $index) {
<p>Disabled because {{ reason.message }}</p>
}
The functions hidden and readonly can be used to hide or make fields read-only in the same way. However, they alway return boolean values and don't support reasons.
While Signal Forms automatically disables writing to bound fields, e.g., input elements, marked as read-only, hiding fields is left to the application. This is because usually, hiding a field also requires hiding its label and perhaps other UI elements. Hence, hidden is only a hint your template can use:
@if (!flightForm.delay().hidden()) {
<div class="form-group">
<label for="flight-delay">Delay (min)</label>
<input
class="form-control"
[formField]="flightForm.delay"
type="number"
id="flight-delay"
/>
</div>
}
Debouncing in Signal Forms Explained
Especially in reactive UIs with search inputs, debouncing is essential. Instead of firing an HTTP request on every keystroke, we wait until the user has stopped typing for a few milliseconds. In our example, this behavior appears in the flight search form:

The form's behavior for debouncing is specified in its schema. For this, the debounce function can be used:
// .../feature-booking/reactive-flight-search/reactive-flight-search.ts
[...]
import { debounce, form, minLength, required } from "@angular/forms/signals";
[...]
protected readonly filterForm = form(this.filter, (schema) => {
debounce(schema, 300);
required(schema.from);
minLength(schema.from, 3);
});
In most cases, just calling debounce with the debounce time in milliseconds is exactly what you need. However, if you want to control the debounce time using code, you can also pass in a respective debouncer function:
filterForm = form(this.filter, (schema) => {
debounce(schema, (ctx, _abortSignal) => {
return new Promise((resolve) => {
setTimeout(resolve, 300);
});
});
required(schema.from);
minLength(schema.from, 3);
});
Now, the returned Promise controls the debouncing. When it resolves, Angular continues processing the changes.
Validating Against Zod and Standard Schema
Often, there is already something, such as a Zod schema, that describes rules for valid objects. Perhaps you already have them for server-side code or you generate it from a JSON Schema or an Open API document. Fortunately, you can directly validate against such schemas:
// src/app/domains/ticketing/data/flight-schema.ts
import { validateStandardSchema, schema } from "@angular/forms/signals";
import { FlightZodSchema } from "./flight-zod-schema";
[...]
export const flightSchema = schema<Flight>((path) => {
validateStandardSchema(path, FlightZodSchema);
// ... other validation rules
});
This not only works with Zod but also with all other schema libraries that follow the Standard Schema definition.
Combined with the submit function discussed above, this is a quick and effective way to establish a client-side validation.
Field Validation
Validation is a crucial aspect of form handling. Signal Forms provide a comprehensive validation system that goes far beyond simple required fields. You can create custom validators, implement conditional validation rules, validate multiple fields together, and even perform asynchronous validation via HTTP calls. This section explores the various validation strategies available in Signal Forms.
Implementing Custom Validators for Signal Forms
Custom validators allow you to implement validation logic that goes beyond the built-in validators. They can check against business rules and compare values. To set them up, the validate function is used. It accepts the path to be validated and a lambda expression that performs the validation:
import { validate } from "@angular/forms/signals";
[...]
protected readonly flightForm = form(this.flight, (path) => {
required(path.from);
required(path.to);
required(path.date);
minLength(path.from, 3);
const allowed = ['Graz', 'Hamburg', 'Zürich'];
validate(path.from, (ctx) => {
const value = ctx.value();
if (allowed.includes(value)) {
return null;
}
return {
kind: 'city',
value,
allowed,
};
});
});
This example defines a custom rule that checks the field from against a list of allowed airports. In case of an error, this validator returns a ValidationError that identifies the validation error with a string kind. The validator can provide more detailed error information using additional properties it can freely specify. If a validator detects multiple errors, it can also return an array of ValidationError objects. If there are no errors, it returns the value null – true to the motto: no complaints are praise enough.
Refactoring Validators into Functions
To improve clarity and reuse, it is advisable to refactor custom validators to separate functions. They must at least accept the property to be validated. The type SchemaPathTree<T> is used for this purpose. A SchemaPathTree<string>, for example, refers to a property of type string:
// src/app/domains/ticketing/data/flight-validators.ts
import { SchemaPathTree, validate } from "@angular/forms/signals";
export function validateCity(path: SchemaPathTree<string>, allowed: string[]) {
validate(path, (ctx) => {
const value = ctx.value();
if (allowed.includes(value)) {
return null;
}
return {
kind: "city",
value,
allowed,
};
});
}
The validator shown here accepts the airports passed to the allowed parameter. To use the validator, the application calls it in the schema:
// src/app/domains/ticketing/data/flight-schema.ts
import { validateCity } from "./flight-validators";
[...]
export const flightSchema = schema<Flight>((path) => {
[...]
validateCity(path.from, ["Graz", "Hamburg", "Zürich"]);
});
Showing Validation Errors
So far, we've only returned the errors object to indicate validation errors. However, the validators provided by Angular also give us the option to specify an error message for the user:
required(path.from, { message: "Please enter a value!" });
The validator places this error message in the message property of the respective ValidationError object. This means the application only needs to output all message properties it finds in the errors array. In our example, the ValidationErrorsPane component handles this task. If a validation error does not have a message property, a standard error message determined by the helper function toMessage is used:
// src/app/domains/shared/ui-forms/validation-errors/validation-errors-pane.ts
import { Component, computed, input } from "@angular/core";
import { MinValidationError, ValidationError } from "@angular/forms/signals";
@Component({
selector: "app-validation-errors-pane",
imports: [],
templateUrl: "./validation-errors-pane.html",
})
export class ValidationErrorsPane {
readonly errors = input.required<ValidationError.WithField[]>();
readonly showFieldNames = input(false);
protected readonly errorMessages = computed(() =>
toErrorMessages(this.errors(), this.showFieldNames()),
);
}
function toErrorMessages(
errors: ValidationError.WithField[],
showFieldNames: boolean,
): string[] {
return errors.map((error) => {
const prefix = showFieldNames ? toFieldName(error) + ": " : "";
const message = error.message ?? toMessage(error);
return prefix + message;
});
}
function toFieldName(error: ValidationError.WithField) {
return error.fieldTree().name().split(".").at(-1);
}
function toMessage(error: ValidationError): string {
switch (error.kind) {
case "required":
return "Enter a value!";
case "roundtrip":
case "roundtrip_tree":
return "Roundtrips are not supported!";
case "min":
return Minimum amount: ${(error as MinValidationError).min};
default:
return error.kind ?? "Validation Error";
}
}
The template displays the error messages obtained in this way:
<!-- src/app/domains/shared/ui-forms/validation-errors/validation-errors-pane.html -->
@if (errorMessages().length > 0) {
<div class="validation-errors">
@for(message of errorMessages(); track message) {
<div>{{ message }}</div>
}
</div>
}
The consumer must now delegate to the ValidationErrorsPane component for each field and pass the errors array:
<!-- .../ticketing/feature-booking/flight-edit/flight-form/flight-form.html -->
<div class="form-group">
<label for="flight-from">From</label>
<input [formField]="flightForm.from" id="flight-from" />
<app-validation-errors-pane [errors]="flightForm.from().errors()" />
</div>
Conditional Validation
Some validation rules only apply under specific circumstances. In the application shown, delay needs to be validated only when delayed is true. This simple case is representative of more complex cases in which the value of one field affects the validation of other fields.
Such situations can be handled with the applyWhenValue function. It accepts the path to be validated, a predicate — typically a lambda expression — and a schema. If the predicate returns true, applyWhenValue applies the schema:
// src/app/domains/ticketing/data/flight-schema.ts
import { applyWhenValue, required, min, schema } from "@angular/forms/signals";
[...]
export const flightSchema = schema<Flight>((path) => {
// ...
applyWhenValue(path, (flight) => flight.delayed, delayedFlight);
});
export const delayedFlight = schema<Flight>((path) => {
required(path.delay);
min(path.delay, 15);
});
The schema contains the conditional validation rules. There are a few alternatives to applyWhenValue. One of them is applyWhen, whose predicate receives the current context, which provides access to the entire field state:
applyWhen(path, (ctx) => ctx.valueOf(path.delayed), delayedFlight);
Besides valueOf, the context also provides a function stateOf that returns the entire field state of a given path. This is useful if the predicate needs to check properties such as dirty or touched.
Another option for conditional validations is the when property provided by the required validator:
required(path.delay, {
when: (ctx) => ctx.valueOf(path.delayed),
});
Cross-Field Validation in Signal Forms
Sometimes we need to respect multiple fields to perform a validation. One option for achieving this is a validator provided for a common parent. For example, if a validator needs to ensure that from and to have different values, the application can implement it as a validator for the entire flight:
// src/app/domains/ticketing/data/flight-validators.ts
import { SchemaPathTree, validate } from "@angular/forms/signals";
import { Flight } from "./flight";
export function validateRoundTrip(schema: SchemaPathTree<Flight>) {
validate(schema, (ctx) => {
const from = ctx.fieldTree.from().value();
const to = ctx.fieldTree.to().value();
// Alternative:
// const from = ctx.valueOf(schema.from);
// const to = ctx.valueOf(schema.to);
if (from === to) {
return {
kind: "roundtrip",
from,
to,
};
}
return null;
});
}
As usual, this validator must be included in the schema:
// src/app/domains/ticketing/data/flight-schema.ts
import { validateRoundTrip } from "./flight-validators";
export const flightSchema = schema<Flight>((path) => {
// ...
validateRoundTrip(path);
});
Validators always return error messages for the validated level. In this case, this is the flight itself, not from or to. Therefore, the application also uses the flight's errors array:
<!-- src/app/domains/ticketing/feature-booking/flight-edit/flight-edit.html -->
<app-validation-errors-pane [errors]="flightForm().errors()" />
<form>[…]</form>
In the screenshot shown initially, this is the first error message the component displays above the form. Validation errors from lower levels do not appear in errors. To retrieve these, the application can access the errors array on the respective level or a parent's errorSummary:
<app-validation-errors-pane [errors]="flightForm().errorSummary()" />
<form>[…]</form>
Since this will reveal error messages for different levels and fields, it makes sense to output the name of the affected field for each error. This information can also be found in the individual ValidationError objects. The ValidationErrorsPane component in my examples (see branch signal-forms-example), which is somewhat more comprehensive than the variant discussed here, takes advantage of this option.
Accessing Sibling Fields
An alternative implementation of the roundtrip validator in the previous section is to define a validator for from that also has access to its sibling field to. This is done using the valueOf function provided by the validation context:
export function validateRoundTrip2(schema: SchemaPathTree<Flight>) {
// Now, we are validating the 'from' field only
validate(schema.from, (ctx) => {
const from = ctx.value();
const to = ctx.valueOf(schema.to);
if (from === to) {
return {
kind: 'roundtrip',
from,
to,
};
}
return null;
});
}
In this case, the error message appears in the from field's errors array:
<app-validation-errors-pane [errors]="flightForm.from().errors()" />
Tree Validators
Tree validators are special multi-field validators that can define error messages for all levels of a field tree. To do so, they store the affected field in the ValidationError object:
// src/app/domains/ticketing/data/flight-validators.ts
import { SchemaPathTree, validateTree } from "@angular/forms/signals";
import { Flight } from "./flight";
export function validateRoundTripTree(schema: SchemaPathTree<Flight>) {
validateTree(schema, (ctx) => {
const from = ctx.fieldTree.from().value();
const to = ctx.fieldTree.to().value();
if (from === to) {
return {
kind: "roundtrip_tree",
field: ctx.fieldTree.from,
from,
to,
};
}
return null;
});
}
After registering in the schema, the error message now appears at field level:
// src/app/domains/ticketing/data/flight-schema.ts
import { validateRoundTripTree } from "./flight-validators";
export const flightSchema = schema<Flight>((path) => {
// ...
validateRoundTripTree(path);
});
In the initial screenshot shown above, this error message is the second one from the top.
Since each validator can return multiple error messages in the form of an array, a tree validator can also return error messages for different fields. This makes it ideal for complex rules. Simple rules, however, can also be implemented as conventional validators that have access to neighboring fields:
validate(path.to, (ctx) => {
if (ctx.value() === ctx.valueOf(path.from)) {
return { kind: "roundtrip" };
}
return null;
});
Asynchronous Validators for Signal Forms
Asynchronous validators do not deliver validation results instantly; they determine them asynchronously. Typically, this is done via an HTTP call. The validateAsync function provided by Signal Forms enables this approach. It defines four mappings:
paramsmaps the form state to parametersfactorycreates a resource with these parametersonSuccessmaps the result determined by the resource to aValidationErroror aValidationErrorarrayonErrormaps a possible error returned by the resource to aValidationErroror aValidationErrorarray
The following listing demonstrates this using an rxResource that uses the rxValidateAirport function to simulate an HTTP call for validating an airport:
// src/app/domains/ticketing/data/flight-validators.ts
import { rxResource } from "@angular/core/rxjs-interop";
import { SchemaPathTree, validateAsync, metadata } from "@angular/forms/signals";
import { delay, map, Observable, of } from "rxjs";
export function validateCityAsync(schema: SchemaPathTree<string>) {
validateAsync(schema, {
params: (ctx) => ({
value: ctx.value(),
}),
factory: (params) => {
return rxResource({
params,
stream: (p) => {
return rxValidateAirport(p.params.value);
},
});
},
onSuccess: (result: boolean, _ctx) => {
if (!result) {
return {
kind: "airport_not_found_http",
};
}
return null;
},
onError: (error, _ctx) => {
console.error("api error validating city", error);
return {
kind: "api-failed",
};
},
});
}
// Simulates a server-side validation
function rxValidateAirport(airport: string): Observable<boolean> {
const allowed = ["Graz", "Hamburg", "Zürich"];
return of(null).pipe(
delay(2000),
map(() => allowed.includes(airport)),
);
}
As usual, this validator must also be added to the schema:
// src/app/domains/ticketing/data/flight-schema.ts
import { validateCityAsync } from "./flight-validators";
[...]
export const flightSchema = schema<Flight>((path) => {
[...]
validateCityAsync(path.from);
});
As long as at least one synchronous validator fails, Angular doesn't execute asynchronous validators. This prevents unnecessary server calls. While Signal Forms waits for the result of an asynchronous validator, the pending property of the affected field is true:
<!-- .../ticketing/feature-booking/flight-edit/flight-form/flight-form.html -->
@if (flightForm.from().pending()) {
<div>Waiting for Async Validation Result...</div>
}
HTTP Validators
Most asynchronous validators initiate HTTP requests. Therefore, validateHttp provides a simplified version that directly returns a request for an HttpResource:
// src/app/domains/ticketing/data/flight-validators.ts
import { SchemaPathTree, validateHttp, metadata } from "@angular/forms/signals";
import { Flight } from "./flight";
import { CITY } from "../../shared/util-common/properties";
export function validateCityHttp(schema: SchemaPathTree<string>) {
metadata(schema, CITY, () => true);
validateHttp(schema, {
request: (ctx) => ({
url: "https://demo.angulararchitects.io/api/flight",
params: {
from: ctx.value(),
},
}),
onSuccess: (result: Flight[], _ctx) => {
if (result.length === 0) {
return {
kind: "airport_not_found_http",
};
}
return null;
},
onError: (error, _ctx) => {
console.error("api error validating city", error);
return {
kind: "api-failed",
};
},
});
}
As usual, onSuccess maps the result of the resource to ValidationError objects and onError projects errors provided by the resource to this type. Here, too, you should remember to anchor the validator in the schema:
// src/app/domains/ticketing/data/flight-schema.ts
import { validateCityHttp } from "./flight-validators";
[...]
export const flightSchema = schema<Flight>((path) => {
[...]
validateCityHttp(path.to);
});
Nested Forms and Form Arrays
Real-world applications often require forms that go beyond simple flat structures. Signal Forms support complex, nested data models with objects and arrays. This section demonstrates how to work with form groups (nested objects) and form arrays (repeating groups), and how to break down large forms into smaller, more manageable sub-components.
Form Groups
So far, we've only bound a flat flight object to a form. However, Signal Forms also support more complex structures. To illustrate this, let's write validation rules for the aircraft object connected to a flight:
// src/app/domains/ticketing/data/aircraft-schema.ts
import { required, schema } from "@angular/forms/signals";
import { Aircraft } from "./aircraft";
export const aircraftSchema = schema<Aircraft>((path) => {
required(path.registration);
required(path.type);
});
The flight schema uses the aircraft schema:
// src/app/domains/ticketing/data/flight-schema.ts
import { apply } from "@angular/forms/signals";
import { aircraftSchema } from "./aircraft-schema";
[...]
export const flightSchema = schema<Flight>((path) => {
[...]
apply(path.aircraft, aircraftSchema);
});
The aircraft can be directly accessed via the flight. To avoid long chains like flightForm.aircraft.registration().errors(), I create an alias for each additional level using let:
<!--
.../ticketing/feature-booking/flight-edit/aircraft-form/aircraft-form.html
-->
@let aircraftForm = aircraft();
<fieldset>
<legend>Aircraft</legend>
<div class="form-group">
<label for="type">Type:</label>
<input id="type" class="form-control" [formField]="aircraftForm.type" />
<app-validation-errors-pane [errors]="aircraftForm.type().errors()" />
</div>
<div class="form-group">
<label for="registration">Registration:</label>
<input
id="registration"
class="form-control"
[formField]="aircraftForm.registration"
/>
<app-validation-errors-pane
[errors]="aircraftForm.registration().errors()"
/>
</div>
</fieldset>
Form Arrays
The repeating group with the prices is designed similarly to the aircraft form. First, you need to set up a schema:
// src/app/domains/ticketing/data/price-schema.ts
import { min, required, schema } from "@angular/forms/signals";
import { Price } from "./price";
export const initPrice: Price = {
flightClass: "",
amount: 0,
};
export const priceSchema = schema<Price>((path) => {
required(path.flightClass);
required(path.amount);
min(path.amount, 0);
});
Unlike before, the flight schema must set up the pricing scheme for each individual price. Therefore, the applyEach function is used here instead of apply:
// src/app/domains/ticketing/data/flight-schema.ts
import { applyEach, schema } from "@angular/forms/signals";
import { priceSchema } from "./price-schema";
export const flightSchema = schema<Flight>((path) => {
// ...
applyEach(path.prices, priceSchema);
});
In the template, the prices must now be iterated, and corresponding fields must be presented for each price:
<!-- .../ticketing/feature-booking/flight-edit/prices-form/prices-form.html -->
@let priceForms = prices();
<fieldset>
<legend>Prices</legend>
<app-validation-errors-pane [errors]="priceForms().errors()" />
<table class="datagrid">
<tr>
<th>Flight Class</th>
<th>Amount</th>
<th></th>
</tr>
@for (price of priceForms; track $index) {
<tr>
<td>
<input [formField]="price.flightClass" class="medium" />
</td>
<td>
<input [formField]="price.amount" type="number" class="small" />
</td>
<td class="error-col">
<app-validation-errors-pane
[errors]="price().errorSummary()"
[showFieldNames]="true"
/>
</td>
</tr>
}
</table>
<button (click)="addPrice()" type="button" class="btn btn-default ml3">
Add
</button>
</fieldset>
The Add button adds a new price to the array. This causes Angular to present fields for it as well:
addPrice(): void {
const pricesForms = this.prices();
pricesForms().value.update((prices) => [...prices, { ...initPrice }]);
}
Validating Form Arrays
Validation rules can be defined for all nodes in the object graph shown. Arrays like our prices are no exception. The following listing demonstrates this using a validator that iterates through all prices and checks for duplicates:
// src/app/domains/ticketing/data/flight-validators.ts
import { SchemaPath, validate } from "@angular/forms/signals";
import { Price } from "./price";
export function validateDuplicatePrices(prices: SchemaPath<Price[]>) {
validate(prices, (ctx) => {
const prices = ctx.value();
const flightClasses = new Set<string>();
for (const price of prices) {
if (flightClasses.has(price.flightClass)) {
return {
kind: "duplicateFlightClass",
message: "There can only be one price per flight class",
flightClass: price.flightClass,
};
}
flightClasses.add(price.flightClass);
}
return null;
});
}
As always, you have to remember to include the validation rule:
// src/app/domains/ticketing/data/flight-schema.ts
import { validateDuplicatePrices } from "./flight-validators";
[...]
export const flightSchema = schema<Flight>((path) => {
[...]
validateDuplicatePrices(path.prices);
});
Nested Forms (Subforms)
The code of large forms can quickly become unwieldy. Therefore, it's a good idea to break them down into multiple components. Let's imagine that our flight form is composed of three types: the flight itself, an aircraft, and prices:

For these parts, we could define subforms. The main FlightEdit component then imports these components:
// src/app/domains/ticketing/feature-booking/flight-edit/flight-edit.ts
[...]
import { AircraftForm } from './aircraft-form/aircraft-form';
import { FlightForm } from './flight-form/flight-form';
import { PricesForm } from './prices-form/prices-form';
@Component({
selector: 'app-flight-edit',
imports: [
AircraftForm,
PricesForm,
FlightForm,
ValidationErrorsPane,
RouterLink,
],
templateUrl: './flight-edit.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FlightEdit {
[...]
}
These components each receive a portion of the entire Signal Form via data binding:
<!-- src/app/domains/ticketing/feature-booking/flight-edit/flight-edit.html -->
@if (flight().id !== 0) {
<app-validation-errors-pane [errors]="flightForm().errors()" />
<form class="flight-form" novalidate>
<app-flight [flight]="flightForm"></app-flight>
<app-prices [prices]="flightForm.prices"></app-prices>
<app-aircraft [aircraft]="flightForm.aircraft"></app-aircraft>
<div class="mt-40">
<button type="button" (click)="save()">Save</button>
</div>
</form>
}
Angular represents these individual parts as FieldTree<T>. Inputs must be set up for this:
// .../ticketing/feature-booking/flight-edit/aircraft-form/aircraft-form.ts
import { Component, input } from "@angular/core";
import { FieldTree, FormField } from "@angular/forms/signals";
import { ValidationErrorsPane }
from "../../../../shared/ui-forms/validation-errors/validation-errors-pane";
import { Aircraft } from "../../../data/aircraft";
@Component({
selector: "app-aircraft",
imports: [FormField, ValidationErrorsPane],
templateUrl: "./aircraft-form.html",
})
export class AircraftForm {
aircraft = input.required<FieldTree<Aircraft>>();
}
For arrays like the price array in our example, the same procedure applies:
// .../ticketing/feature-booking/flight-edit/prices-form/prices-form.ts
import { Component, input } from "@angular/core";
import { FieldTree, FormField } from "@angular/forms/signals";
import { ValidationErrorsPane }
from "../../../../shared/ui-forms/validation-errors/validation-errors-pane";
import { Price } from "../../../data/price";
import { initPrice } from "../../../data/price-schema";
@Component({
selector: "app-prices",
imports: [FormField, ValidationErrorsPane],
templateUrl: "./prices-form.html",
})
export class PricesForm {
readonly prices = input.required<FieldTree<Price[]>>();
addPrice(): void {
const pricesForms = this.prices();
pricesForms().value.update((prices) => [...prices, { ...initPrice }]);
}
}
How Form Metadata Works?
Instead of telling users after they entered something invalid, Signal Forms can provide metadata that tells them upfront what they are expected to provide:

For displaying the metadata, our example uses a component app-field-meta-data-pane that is imported into the flight form:
[...]
import { FieldMetaDataPane } from '../../../shared/ui-forms/field-meta-data-pane/field-meta-data-pane';
[...]
@Component({
selector: 'app-flight-edit',
imports: [[...], FieldMetaDataPane],
[...]
})
export class FlightEdit {
[...]
}
In the template, it displays the metadata next to the respective field:
<!-- .../ticketing/feature-booking/flight-edit/flight-form/flight-form.html -->
<div class="form-group">
<label for="flight-from">
From
<app-field-meta-data-pane [field]="flightForm.from" />
</label>
<input class="form-control" [formField]="flightForm.from" id="flight-from" />
[...]
</div>
How to Read Form Metadata?
To read the metadata, call the metadata method with a key, e.g., REQUIRED or MIN_LENGTH. As we will see in a second, you can also define your own keys, such as CITY or CITY2. What you get back is either the metadata property itself or a Signal containing the property (more about this below), which you can display to the user:
// src/app/domains/shared/ui-forms/field-meta-data-pane/field-meta-data-pane.ts
import { Component, computed, input } from "@angular/core";
import {
FieldTree,
REQUIRED,
MIN_LENGTH,
MAX_LENGTH,
} from "@angular/forms/signals";
import { CITY, CITY2 } from "../../util-common/properties";
@Component({
selector: "app-field-meta-data-pane",
imports: [],
templateUrl: "./field-meta-data-pane.html",
})
export class FieldMetaDataPane {
readonly field = input.required<FieldTree<unknown>>();
protected readonly fieldState = computed(() => this.field()());
protected readonly isRequired = computed(
() => this.fieldState().metadata(REQUIRED)?.() ?? false,
);
protected readonly minLength = computed(
() => this.fieldState().metadata(MIN_LENGTH)?.() ?? 0,
);
protected readonly maxLength = computed(
() => this.fieldState().metadata(MAX_LENGTH)?.() ?? 30,
);
protected readonly length = computed(
() => (${this.minLength()}..${this.maxLength()}),
);
protected readonly city = computed(() => this.fieldState().metadata(CITY));
protected readonly city2 = computed(() => this.fieldState().metadata(CITY2));
}
Here is the template for this component:
<!-- .../shared/ui-forms/field-meta-data-pane/field-meta-data-pane.html -->
@if (isRequired()) {
<span class="info">*</span>
}
<span class="info info-small">{{ length() }}</span>
@if (city()) {
<span class="info info-small">City</span>
} @if (city2()) {
<span class="info info-small">City</span>
}
How to Define Custom Form Metadata?
You create your own simple property with createMetadataKey:
// src/app/domains/shared/util-common/properties.ts
import { createMetadataKey, MetadataReducer } from "@angular/forms/signals";
//
// Property
//
export const CITY = createMetadataKey<boolean>();
//
// AggregateProperty
//
export const CITY2 = createMetadataKey(MetadataReducer.or());
Now it gets a bit spicy: Let's imagine two validators define different values for the same property. The first one defines CITY as true, the second one as false. By default, the last defined value wins. However, if you define a reducer like CITY2, you can control how multiple values are combined.
The reducer MetadataReducer.or() combines them via a logical OR (value1 || value2 || value3). Besides this, there is, for instance, MetadataReducer.and(), MetadataReducer.min(), MetadataReducer.max(), and MetadataReducer.list(). The latter puts all values into an array.
For special cases, you can also implement your own reducer by implementing the MetadataReducer<T> interface. To illustrate this, here is a custom reducer that also combines boolean values via a logical OR:
const myOr: MetadataReducer<boolean, boolean> = {
reduce(acc, item) {
return acc || item;
},
getInitial() {
return false;
}
};
export const CITY3 = createMetadataKey(myOr);
To define a value for a metadata key, we use the metadata function. You can call it directly in the schema, but most likely, you'll call it inside a custom validator where you already define what you expect from the user:
// src/app/domains/ticketing/data/flight-validators.ts
export function validateCityHttp(schema: SchemaPath<string>) {
metadata(schema, CITY, () => true);
validateHttp(schema, { ... });
}
Null and Undefined Values
It might come as a surprise that Signal Forms do not allow undefined values. The reason for this design decision is that undefined semantically means that the field does not exist. When initialized with an undefined value, Signal Forms's form function has no way of knowing that this field is supposed to exist in the form.
To make this clearer, let’s use a thought experiment where the delay of our flights is optional:
export interface FlightDomainModel {
id: string;
from: string;
to: string;
date: string;
delayed: boolean;
// Optional delay
delay?: number
}
Let's further assume that the backend only expects a delay value when delayed is true. In all other cases, the field is omitted and hence undefined. If you now create a Signal Form for this model in a component without a delay, Angular Signal forms cannot find the needed metadata for the delay field. From it's perspective, this field does not exist.
However, from the form's perspective, the field always exists while sometimes it has no value. This shows that we have a different perspective from the domain perspective than from the form perspective. For this reason the Angular team recommends to distinguish between the two perspectives by defining two separate types:
export interface FlightFormModel {
id: string;
from: string;
to: string;
date: string;
delayed: boolean;
delay: number
}
Instead of undefined you can use null as it has the semantic of 'empty value'. However, it would be even better to go with a sound default value. In our example, we can simply set the default delay to 0. To bridge between the domain model and the form model, we can use mapping functions when creating the Signal Form:
export function toFlightFormModel(model: FlightDomainModel): FlightFormModel {
return {
...model,
delay: model.delay ?? 0,
};
}
For converting back we could use a similar function, given the backend does not accept a delay of 0 when delayed is false:
export function toFlightDomainModel(model: FlightFormModel): FlightDomainModel {
return {
...model,
delay: model.delayed ? undefined : model.delay,
};
}
Furthermore, in our component we can use a linked signal to automatically convert the domain model to the form model within the reactive data flow:
protected readonly flightDomainModel = signal<FlightDomainModel>({
id: 0,
from: '',
to: '',
date: '',
delayed: false,
});
protected readonly flightFormModel = linkedSignal(
() => toFlightFormModel(this.flightDomainModel())
);
protected readonly flightForm = form(this.flightFormModel);
When saving the form, we need to convert back to the domain model:
protected save(): void {
const formModel = this.flightForm().value();
const domainModel = toFlightDomainModel(formModel);
[...]
}
When the form model needs to be converted back immediately after typing, a delegated signal as described in chapter xyz can be used.
The same strategy is useful when dealing with fields that appear conditionally. From the form's perspective, these fields always exist, even if they are hidden in the UI. Hence, the correct way for modelling them is to always have them in the form model and to convert them accordingly when mapping to/from the domain model.
Creating Custom Fields
So far, we've only used the FormField directive with standard HTML tags. The question now is how to make this directive work with our own widgets. For example, let's imagine a DelayStepper component to change the flight delay in 15-minute increments. This should be able to be bound to a field with FormField, just like all other fields:
<!-- .../ticketing/feature-booking/flight-edit/flight-form/flight-form.html -->
<div class="form-group form-check">
<label for="delay">Delay</label>
<app-delay-stepper id="delay" [formField]="flightForm.delay" />
<app-validation-errors-pane [errors]="flightForm.delay().errors()" />
</div>
In the past, such widgets had to be provided with a so-called Control Value Accessor. Unfortunately, this wasn't exactly straightforward. Signal Forms makes the whole process much simpler: The widget implements the FormValueControl<T> interface, which just enforces a ModelSignal named value. It also defines other optional properties, such as disabled or errors, which the widget can access as needed:
// src/app/domains/shared/ui-common/delay-stepper/delay-stepper.ts
import { Component, effect, input, model } from "@angular/core";
import { FormValueControl, ValidationError } from "@angular/forms/signals";
@Component({
selector: "app-delay-stepper",
imports: [],
templateUrl: "./delay-stepper.html",
styleUrl: "./delay-stepper.css",
})
export class DelayStepper implements FormValueControl<number> {
readonly value = model(0);
readonly disabled = input(false);
readonly errors = input<readonly ValidationError.WithOptionalField[]>([]);
constructor() {
effect(() => {
console.log("DelayStepper, errors", this.errors());
});
}
protected inc(): void {
this.value.update((v) => v + 15);
}
protected dec(): void {
this.value.update((v) => Math.max(v - 15, 0));
}
}
The DelayStepper component uses the disabled property to indicate whether the field is disabled due to a schema rule. It receives all validation errors from Signal Forms via errors. This property is displayed with an effect for the sake of demonstration. The disabled property is used alongside the value in the template:
<!-- src/app/domains/shared/ui-common/delay-stepper/delay-stepper.html -->
@if(disabled()) {
<div class="delay">No Delay!</div>
} @else {
<div class="delay">{{ value() }}</div>
<div>
<a (click)="inc()">+15 Minutes</a> |
<a (click)="dec()">-15 Minutes</a>
</div>
}
Note on Custom Checkboxes
FormValueControl can also be used for checkboxes, as it provides an optional checked property. However, for this purpose, Signal Forms also provides a specialized FormCheckboxControl interface. It defines a mandatory checked property and an optional value.
Conclusion
Signal Forms show where Angular is heading: toward a form model that is fully reactive, strongly typed, and built around Signals. Instead of treating forms as a separate subsystem, they integrate form state, validation, and UI behavior into one coherent, composable API—scaling from simple fields to nested object graphs and arrays.
What stands out is the breadth of validation options (custom, conditional, multi-field, tree-based, and async/HTTP) and how naturally these rules can be organized via schemas and reusable validator functions. Combined with metadata, forms can not only detect errors but also communicate expectations early, leading to a smoother user experience.
Signal Forms are still experimental, but they are already worth exploring in real-world scenarios: start with a single feature form, extract reusable schemas, and incrementally add advanced validation where it delivers the most value. If you try them early and provide feedback, you can help shape a form API that many Angular apps will benefit from in the near future.
