Build forms around your model, not your framework.
Forms is a reactive form engine for TypeScript. It gives fields, forms, lists, validation, and form state a life outside the UI framework --- then connects them to the DOM when you need it.
If you already have data, the shortest way in is:
import { hydrate } from '@epikodelabs/forms';
const profile = hydrate({
name: 'Ada',
email: 'ada@example.com',
});
profile.fields.name.set('Grace');
console.log(profile.snapshot);
// { name: 'Grace', email: 'ada@example.com' }If you want to describe the form explicitly, you still can:
import { field, form } from '@epikodelabs/forms';
const profile = form({
name: field('Ada'),
email: field('ada@example.com'),
});Both end up with the same idea: the form is a model, not a collection of framework controls.
- One form model. The form tree owns form state; the DOM and frameworks consume it.
- Start from data when that is enough.
hydrate()turns plain form values into a Forms model. - Stay explicit when structure matters.
field(),form(), andlist()remain available for deliberate form topology. - Validation belongs to the model. Sync, async, cross-field, and template validation share one state model.
- Arrays are values by default. An array can be a multi-select
value, an API value, or something else. It becomes dynamic form
topology only when you explicitly use
list(). - Incremental aggregation. Container status updates from the child that changed instead of rescanning the whole tree.
- On-demand snapshots. Wide
snapshotandrawrecords are materialized only when somebody consumes them. - The DOM is an adapter. Native browser binding and Angular use the same underlying form semantics.
- The verifier understands the model. It can check template paths,
validators, hydrated shapes, and statically resolvable
rehydrate()calls before runtime.
npm install @epikodelabs/formsAngular integration lives in @epikodelabs/forms/angular.
@angular/core is an optional peer dependency.
For ordinary data-shaped forms, start with hydrate():
import { hydrate } from '@epikodelabs/forms';
const profile = hydrate({
firstName: 'Ada',
lastName: 'Lovelace',
address: {
city: 'London',
},
roles: ['author'],
});Hydration follows a deliberately small set of rules:
plain object -> Form
scalar -> Field
array -> Field<Array>
That last rule is intentional. This:
hydrate({
roles: ['author', 'reviewer'],
});does not mean "create a dynamic list of controls." roles is one
field whose value happens to be an array --- exactly what a
<select multiple> may need.
When an array really represents dynamic form structure, say so:
import { field, form, list } from '@epikodelabs/forms';
const model = form({
skills: list([
form({
name: field('TypeScript'),
years: field(5),
}),
]),
});
model.fields.skills.push(
form({
name: field('Angular'),
years: field(3),
}),
);No guessing.
hydrate() creates a model. rehydrate() is for a different job:
applying a new external snapshot to a model that already exists.
import {
hydrate,
rehydrate,
} from '@epikodelabs/forms';
const profile = hydrate({
name: '',
country: '',
});
// data arrives later
const user = await loadUser();
rehydrate(profile, user);Rehydration keeps the existing Form, Field, and List instances.
Subscriptions and DOM bindings stay attached, while the new data becomes
the reset baseline.
In other words:
hydrate(data) create model + initial state
rehydrate(form, data) replace external baseline, keep topology
form.set(data) ordinary model mutation
form.patch(data) partial model mutation
rehydrate() does not silently invent new controls or remove existing
ones. If the incoming structure does not match the form topology, it
fails instead.
Form values are plain reads:
profile.snapshot
profile.rawsnapshot is the normal form value and excludes disabled descendants.
raw includes them.
Reactive form state stays granular:
profile.status
profile.issues
profile.dirty
profile.touched
profile.pendingThere is intentionally no value.value / completeValue.value API.
Every node exposes reactive projections such as:
profile.value
profile.completeValue
profile.status
profile.issues
profile.valid
profile.dirty
profile.touched
profile.pending
profile.disabledFields can be changed directly:
profile.fields.name.set('Grace');
profile.fields.name.touch();Containers aggregate their children without requiring the UI framework to own any of that state.
Validation can live directly on fields:
import {
checks,
field,
form,
} from '@epikodelabs/forms';
const profile = form({
name: field('', {
checks: checks.required,
}),
});Cross-field validation belongs on the form:
const credentials = form({
password: field(''),
confirmPassword: field(''),
});
const source = {};
credentials.useChecks(source, value =>
value.password === value.confirmPassword
? null
: { passwordMismatch: true },
);Async field validators receive an AbortSignal, so an obsolete
validation run cannot publish into newer state.
Custom template validation has one registration API:
import {
defineValidator,
} from '@epikodelabs/forms';
defineValidator({
target: 'field',
attribute: 'data-company-email',
validate: context => ({
checks: value => {
// ...
return null;
},
}),
});Form-level validators use the same API:
defineValidator({
target: 'form',
attribute: 'data-password-match',
resolveTarget: context =>
context.attributeValue ?? undefined,
validate: context => value => {
// ...
return null;
},
});Built-in HTML constraints and custom validators ultimately contribute to the same Forms validation state.
Named controls map to Forms paths:
<form id="profile">
<input name="name" required>
<input name="address.city">
<select name="roles" multiple>
<option value="author">Author</option>
<option value="reviewer">Reviewer</option>
</select>
</form>If you already own the form:
import {
bindForm,
hydrate,
} from '@epikodelabs/forms';
const model = hydrate({
name: 'Ada',
address: {
city: 'London',
},
roles: ['author'],
});
const binding = bindForm(
document.querySelector('#profile')!,
model,
);
// later
binding.dispose();
model.dispose();bindForm() owns the DOM connection, not your form.
For the convenient create-and-bind case:
import {
createFormBinding,
} from '@epikodelabs/forms';
const binding = createFormBinding(
document.querySelector('#profile')!,
{
name: 'Ada',
address: {
city: 'London',
},
roles: ['author'],
},
);
console.log(binding.form.snapshot);
// disposes both the DOM binding and the generated form
binding.dispose();bindForm() performs initial discovery and incrementally maintains its
binding plan as the DOM changes. Use bindPlan() when you already have
a static plan.
Async select options do not need special form semantics. Load and render the options whenever they arrive; the selected value remains ordinary Forms field state.
Angular is deliberately thin. Forms still owns the form; Angular gives it a template and lifecycle.
For the common case, pass initial data directly:
import {
Component,
} from '@angular/core';
import {
Forms,
} from '@epikodelabs/forms/angular';
@Component({
standalone: true,
imports: [Forms],
template: `
<form
[formBinding]="initial"
#binding="formBinding"
>
<input name="name" required>
<button
type="submit"
[disabled]="binding.form?.invalid.value"
>
Save
</button>
</form>
`,
})
export class ProfilePage {
readonly initial = {
name: 'Ada',
};
}FormBindingDirective hydrates plain initial data and exposes the
resulting form as binding.form.
You can also provide an existing form:
import {
hydrate,
} from '@epikodelabs/forms';
import {
FormBindingDirective,
} from '@epikodelabs/forms/angular';
@Component({
standalone: true,
imports: [FormBindingDirective],
template: `
<form [formBinding]="profile">
<input name="name">
</form>
`,
})
export class ProfilePage {
readonly profile = hydrate({
name: 'Ada',
});
}Ownership stays predictable:
- plain data passed to
[formBinding]-> the directive owns the generated form; - existing
Formpassed to[formBinding]-> your code owns the form.
Forms is simply the convenient Angular import identity for the
complete Forms template surface. FormBindingDirective remains
available when you prefer explicit imports.
Forms can check form/template agreement project-wide instead of waiting for a user to hit the wrong control at runtime.
npm run verify:formsThe verifier understands explicit forms:
form({
profile: form({
email: field(''),
}),
});plain hydrated input:
readonly initial = {
profile: {
email: '',
},
};and:
const model = hydrate(initial);So a typo such as:
<input name="profile.emial">can be reported against the statically known form shape.
It also understands unified defineValidator() registrations and checks
statically resolvable rehydration:
rehydrate(model, {
profile: {
// missing fields can be diagnosed here
},
});When something genuinely cannot be known statically, the verifier stays conservative and leaves runtime verification in place.
Binding analysis is also available programmatically:
import {
analyzeBindings,
formatBindingDiagnostics,
} from '@epikodelabs/forms';
const analysis = analyzeBindings(host, model);
console.log(
formatBindingDiagnostics(
analysis.diagnostics,
),
);Diagnostics have stable codes and structured details. Missing targets can include conservative path suggestions.
A child write updates incremental container state immediately, but Forms does not rebuild a wide aggregate object just because something changed:
model.fields.firstName.set('Grace');
model.fields.lastName.set('Hopper');
// materialized when consumed
const value = model.snapshot;
// same revision, same cached snapshot
console.log(model.snapshot === value);
// trueSubscriptions still receive stable current and previous snapshots.
Form-level checks consume raw, so they intentionally pay the
aggregate-read cost.
This matters mostly for large forms. For small forms, you can simply enjoy not having to think about it.
npm run build:forms
npm run test:forms
npm run typecheck:verifier
npm run verify:formsThe performance specs are measurement and regression tools rather than hard wall-clock gates. Absolute timings depend on the machine and runner.
MIT