Skip to main content

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

first-form.example.ts
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

choosing-primitives.example.ts
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

array-operations.example.ts
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

dynamic-form-children.example.ts
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

validation.example.ts
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

built-in-validator-error.example.ts
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

submission.example.ts
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

debounce.example.ts
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

async-validation.example.ts
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

reactive-configuration.example.ts
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

angular-binding.typecheck.ts
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

native-form-binding.typecheck.ts
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

built-in-validators.typecheck.ts
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.