Executable and type-checked examples
The examples on this page are rendered directly from real TypeScript files. The documentation
verification compiles every file under strict settings. Files ending in .example.ts are also
bundled and executed, including their assertions; .typecheck.ts files cover Angular declarations
that require the framework runtime. Angular's compiler type-checks their inline templates as part
of the same verification.
These are the canonical, complete versions of the shorter snippets used throughout the guides.
๐ First formโ
Type-checked ยท Executed
import { email, field, form, minLength, required } from '@ngblocks/form-nodes';
const myForm = form({
name: field('', [required, minLength(2)]),
email: field('', [required, email]),
});
if (myForm.valid()) {
throw new Error('The empty required fields should make the form invalid.');
}
myForm.name.set('Ada');
myForm.email.set('ada@example.com');
if (!myForm.valid()) {
throw new Error('The completed form should be valid.');
}
const value = myForm();
if (value.name !== 'Ada' || value.email !== 'ada@example.com') {
throw new Error('The form value did not reflect its child values.');
}
This verifies value inference, required validation, programmatic updates, aggregate validity, and the final form value. Start with Your first form for the guided version.
๐งญ Choosing primitivesโ
Type-checked ยท Executed
import { array, field, form } from '@ngblocks/form-nodes';
type Coordinates = {
latitude: number;
longitude: number;
};
const myForm = form({
location: field<Coordinates>(),
address: {
city: field(''),
country: field(''),
},
contacts: array({
type: field<'email' | 'phone'>('email'),
value: field(''),
}, {
initialValue: 1,
}),
});
myForm.location.set({ latitude: 47.3769, longitude: 8.5417 });
myForm.address.patch({ city: 'Zurich' });
myForm.contacts.push({ type: 'phone', value: '+41 00 000 00 00' });
if (myForm.contacts.length() !== 2 || myForm.address.city() !== 'Zurich') {
throw new Error('The primitive operations produced an unexpected value.');
}
The example deliberately combines an atomic object field, a nested form, and a dynamic array. See Choosing a primitive for the trade-offs.
๐ Array reconciliation and operationsโ
Type-checked ยท Executed
import { array, field, form } from '@ngblocks/form-nodes';
const myForm = form({
people: array({
id: field(''),
name: field(''),
age: field(18),
}, {
initialValue: [
{ id: 'ada', name: 'Ada', age: 36 },
{ id: 'grace', name: 'Grace', age: 44 },
],
trackBy: 'id',
}),
});
const adaNode = myForm.people[0];
const graceNode = myForm.people[1];
myForm.people.set([
{ id: 'grace', name: 'Grace Hopper', age: 45 },
{ id: 'ada', name: 'Ada Lovelace', age: 37 },
]);
if (myForm.people[0] !== graceNode || myForm.people[1] !== adaNode) {
throw new Error('trackBy should preserve nodes across reordering.');
}
myForm.people.at(0)?.patch({ age: 46 });
myForm.people.swap(0, 1);
myForm.people.push({ id: 'linus', name: 'Linus', age: 32 });
if (myForm.people.length() !== 3 || myForm.people[1]?.age() !== 46) {
throw new Error('The array operations produced an unexpected result.');
}
The assertions prove that trackBy preserves item nodes while complete values reorder, then cover
positional patching, swapping, and insertion. Continue with Dynamic arrays.
๐ณ Dynamic form childrenโ
Type-checked ยท Executed
import { field, form } from '@ngblocks/form-nodes';
const profile = form({ name: field('Marco') });
const age = profile.add('age', field(23));
const added = profile.add({
nickname: field('Mark'),
address: {
city: field('Zurich'),
},
});
if (age.nodeType() !== 'field' || added.address.nodeType() !== 'group' || added.address.city.nodeType() !== 'field') {
throw new Error('Dynamic definitions should produce their expected node types.');
}
if (age.parent() !== profile || added.address.city.form() !== profile) {
throw new Error('Dynamic children should join the form tree.');
}
if (profile.get('age') !== age || Reflect.get(profile.children, 'age') !== age) {
throw new Error('Dynamic children should be available through explicit runtime-key lookup.');
}
profile.remove('nickname');
if (profile.get('nickname') !== undefined || Reflect.get(profile.children, 'nickname') !== undefined || added.nickname.parent() !== null) {
throw new Error('Removed children should become detached standalone nodes.');
}
if (JSON.stringify(profile()) !== JSON.stringify({ name: 'Marco', age: 23, address: { city: 'Zurich' } })) {
throw new Error('The form value should contain every current dynamic child.');
}
The assertions cover typed insertion, shorthand-group normalization, lookup, aggregation, and detachment. Continue with Dynamic object children.
โ Validation ownershipโ
Type-checked ยท Executed
import { field, form, minLength, required, uniqueItems } from '@ngblocks/form-nodes';
const registration = form({
username: field('', [required, minLength(3)]),
password: field(''),
confirmation: field(''),
aliases: field<string[]>([], [uniqueItems]),
}, [({ value }) => value().password === value().confirmation
? null
: { kind: 'passwordMismatch' }]);
registration.username.set('Ada');
registration.password.set('secret');
registration.confirmation.set('different');
registration.aliases.set(['countess', 'countess']);
if (!registration.getError('passwordMismatch')) {
throw new Error('The form-level validator should report a password mismatch.');
}
if (!registration.aliases.getError('uniqueItems')) {
throw new Error('The field validator should report duplicate aliases.');
}
registration.confirmation.set('secret');
registration.aliases.set(['countess', 'programmer']);
if (!registration.valid()) {
throw new Error('The corrected registration should be valid.');
}
This combines field-level built-ins with a form-level cross-field rule and checks both failure and recovery. See Validation for composition and reactive dependencies.
๐จ Built-in validator error overridesโ
Type-checked ยท Executed
import { signal } from '@angular/core';
import { field, min } from '../../src/public-api';
const errorKind = signal('minimumAge');
const age = field(16, [
min(18, { error: ({ value }) => ({ kind: errorKind(), actual: value(), minimum: 18 }) })
]);
if (age.errors()[0]?.kind !== 'minimumAge') {
throw new Error('The custom error should replace the built-in minimum error.');
}
errorKind.set('minimumEmploymentAge');
if (age.errors()[0]?.kind !== 'minimumEmploymentAge') {
throw new Error('Signals read by the error function should remain reactive.');
}
const optionalMinimum = field(16, [min(18, { error: () => [] })]);
if (!optionalMinimum.valid()) {
throw new Error('An empty custom error list should suppress the failed rule.');
}
This verifies static failure detection, reactive custom-error replacement, and suppression through an empty error list. See Built-in validators.
๐จ Submissionโ
Type-checked ยท Executed
import { field, form, required } from '@ngblocks/form-nodes';
const savedValues: unknown[] = [];
let invalidAttempts = 0;
const profile = form({
name: field('', [required]),
}, {
onSubmit: async value => {
await Promise.resolve();
savedValues.push(value);
},
onSubmitBlocked: () => {
invalidAttempts += 1;
},
});
const firstResult = await profile.submit();
profile.name.set('Ada');
const secondResult = await profile.submit();
if (firstResult || !secondResult || invalidAttempts !== 1 || savedValues.length !== 1) {
throw new Error('Submission did not follow the expected valid and invalid paths.');
}
Both invalid and valid submission paths execute. The assertions cover return values, onSubmitBlocked,
and the asynchronous action. See Submission.
โฑ๏ธ Control-value debounceโ
Type-checked ยท Executed
import { field, form } from '@ngblocks/form-nodes';
const searchForm = form({
query: field('', {
debounce: 'blur',
}),
});
searchForm.query.value.control.set('angular signals');
if (searchForm.query.value.control() !== 'angular signals') {
throw new Error('The control value should update immediately.');
}
if (searchForm.query() !== '' || !searchForm.query.debouncing()) {
throw new Error('The committed value should wait while blur debounce is active.');
}
if (!searchForm.query.dirty() || searchForm.query.touched()) {
throw new Error('Control input should mark dirty without marking touched.');
}
searchForm.query.markAsTouched();
if (searchForm.query() !== 'angular signals' || searchForm.query.debouncing()) {
throw new Error('Touch should commit the pending control value.');
}
if (!searchForm.query.touched()) {
throw new Error('The field should be touched after the blur-equivalent action.');
}
The assertions distinguish immediate control state from committed model state, then verify dirty, touched, debouncing, and blur-commit behavior. See Value flow and debounce.
โณ Asynchronous validationโ
Type-checked ยท Executed
import { asyncValidator, field, form } from '@ngblocks/form-nodes';
const accountForm = form({
username: field('ada', [
asyncValidator(async ({ value }) => {
await Promise.resolve();
return value() === 'ada' ? { kind: 'usernameTaken' } : null;
}),
]),
});
if (!accountForm.username.pending() || accountForm.username.validationStatus() !== 'unknown') {
throw new Error('The initial asynchronous validation should be pending and unknown.');
}
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
if (accountForm.username.pending() || !accountForm.username.getError('usernameTaken')) {
throw new Error('The completed asynchronous validation should expose its error.');
}
accountForm.username.set('grace');
// Reactive async watchers schedule their rerun after the synchronous value update.
await Promise.resolve();
if (!accountForm.username.pending()) {
throw new Error('Changing the value should start a new asynchronous validation.');
}
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
if (!accountForm.username.valid() || accountForm.username.errors().length !== 0) {
throw new Error('The corrected username should become valid.');
}
This runs both failing and recovering async validation transitions, including pending and unknown status. See Async validation.
โ๏ธ Reactive configuration and precedenceโ
Type-checked ยท Executed
import { signal } from '@angular/core';
import { configureGlobalFormNodes, field, form, required } from '@ngblocks/form-nodes';
const locale = signal<'en' | 'es'>('en');
const locked = signal(false);
const restoreMessages = configureGlobalFormNodes({
validatorMessages: {
required: 'Global required message.',
},
});
try {
const profileForm = form({
displayName: field('', [required]),
}, {
disabled: () => locked() ? 'The profile is locked.' : false,
validatorMessages: () => ({
required: locale() === 'es'
? 'Este campo es obligatorio.'
: 'This field is required.',
}),
});
if (profileForm.displayName.getError('required')?.message !== 'This field is required.') {
throw new Error('The form-tree catalog should override the global catalog.');
}
locale.set('es');
if (profileForm.displayName.getError('required')?.message !== 'Este campo es obligatorio.') {
throw new Error('The selected validator message should react to locale changes.');
}
locked.set(true);
if (!profileForm.displayName.disabled()) {
throw new Error('Configured disabled state should propagate to descendants.');
}
if (profileForm.displayName.disabledReasons()[0]?.message !== 'The profile is locked.') {
throw new Error('The inherited disabled reason should remain observable.');
}
locked.set(false);
if (profileForm.disabled() || profileForm.displayName.disabled()) {
throw new Error('The subtree should become enabled when its configured cause clears.');
}
} finally {
restoreMessages();
}
This verifies reactive message catalogs, tree-over-global precedence, inherited disabled state, and disabled reasons. See the Configuration reference.
๐ Angular binding and viewChild()โ
Type-checked
import { Component, viewChild } from '@angular/core';
import { field, form, FormNodeDirective } from '@ngblocks/form-nodes';
@Component({
selector: 'app-profile-editor',
imports: [FormNodeDirective],
template: `
<label>
Name
<input #nameBinding="formNode" [formNode]="form.name" />
</label>
<label>
Age
<input type="number" [formNode]="form.age" />
</label>
<p>{{ form.name() }} ยท {{ form.age() }}</p>
<button type="button" (click)="focusName()">Focus name</button>
`,
})
export class ProfileEditor {
form = form({
name: field(''),
age: field<number>(),
});
readonly nameBinding = viewChild.required<FormNodeDirective<typeof this.form.name>>('nameBinding');
focusName() {
this.nameBinding().focus();
}
}
This is compiled as an Angular component example. It verifies the standalone FormNodeDirective import,
template binding, typed FormNode, and viewChild.required() query. See
Control binding.
๐ Native form bindingโ
Type-checked
import { Component } from '@angular/core';
import { FormNodeDirective, email, field, form, required } from '@ngblocks/form-nodes';
@Component({
selector: 'app-account-editor',
imports: [FormNodeDirective],
template: `
<form [formNode]="form">
<label>
Email
<input type="email" [formNode]="form.email" />
</label>
@if (form.email.touched() && form.email.invalid()) {
<p>{{ form.email.errors()[0]?.message }}</p>
}
<button type="reset">Reset interaction state</button>
<button type="submit" [disabled]="form.submitting()">Save</button>
</form>
`,
})
export class AccountEditor {
form = form({
email: field('', [required, email]),
}, {
onSubmit: async value => {
await Promise.resolve(value);
},
});
}
Angular's compiler verifies that one FormNode import binds both the native form root and its
controls, including reset, submit, errors, and submission state. See
Form submission.
๐ Every built-in validator signatureโ
Type-checked
import { between, dateBetween, email, equalTo, field, form, integer, max, maxDate, maxLength, maxWords, min, minDate, minLength, minWords, oneOf, pattern, required, uniqueItems, url } from '@ngblocks/form-nodes';
const password = field('');
const myForm = form({
name: field('', [required, minLength(2), maxLength(80)]),
biography: field('', [minWords(2), maxWords(200)]),
age: field<number>(null, [integer, min(18), max(120), between(18, 120)]),
email: field('', [email]),
website: field('', [url]),
role: field('', [oneOf(['admin', 'editor', 'viewer'])]),
code: field('', [pattern(/^[A-Z]{3}$/)]),
startDate: field<Date>(null, [minDate('2026-01-01'), maxDate('2026-12-31')]),
eventDate: field<Date>(null, [dateBetween('2026-01-01', '2026-12-31')]),
password,
confirmation: field('', [equalTo(() => password())]),
tags: field<string[]>([], [uniqueItems]),
});
void myForm;
This compilation fixture keeps the common call signature of every built-in validator aligned with the public API. The built-in validator reference documents each validator individually, including options, empty-value behavior, and error shape.
๐ก What remains a documentation snippetโ
Not every code block should become a program. The following stay inline intentionally:
- isolated signatures and return-type illustrations;
- one-line alternatives shown side by side;
- incomplete fragments whose surrounding setup is the subject of the page;
- HTML-only template fragments; and
- deliberately invalid TypeScript used to explain a compiler error.
When a new example claims observable runtime behavior, prefer adding an executable assertion here
or in another .example.ts file. When it demonstrates Angular integration or public inference,
prefer a .typecheck.ts fixture.