Skip to content

Repository files navigation

Forms

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.

Why Forms

  • 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(), and list() 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 snapshot and raw records 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.

Install

npm install @epikodelabs/forms

Angular integration lives in @epikodelabs/forms/angular. @angular/core is an optional peer dependency.

Hydrate a form

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.

Rehydrate existing forms

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.

Snapshots

Form values are plain reads:

profile.snapshot
profile.raw

snapshot 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.pending

There is intentionally no value.value / completeValue.value API.

Form state

Every node exposes reactive projections such as:

profile.value
profile.completeValue
profile.status
profile.issues
profile.valid
profile.dirty
profile.touched
profile.pending
profile.disabled

Fields 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

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.

Template validators

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.

Native DOM binding

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

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 Form passed 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.

Verifier

Forms can check form/template agreement project-wide instead of waiting for a user to hit the wrong control at runtime.

npm run verify:forms

The 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 diagnostics

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.

On-demand aggregate snapshots

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);
// true

Subscriptions 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.

Documentation

Development

npm run build:forms
npm run test:forms
npm run typecheck:verifier
npm run verify:forms

The performance specs are measurement and regression tools rather than hard wall-clock gates. Absolute timings depend on the machine and runner.

License

MIT

About

Framework-independent reactive forms library for TypeScript. It provides typed fields, nested forms, validation, and DOM binding with incremental state updates and on-demand aggregate snapshots.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages