formodel
formodel
Typed, framework-agnostic form configuration for TypeScript. Describe your form as a plain data model, and formodel's fluent builder hands back a strongly-typed, serializable config you can render with React, Vue, Angular, Svelte, or vanilla JavaScript.
formodel is a tiny, zero-dependency TypeScript library for building type-safe UI form configs from a data model. You configure each field once; the builder infers every control's node type and removes that field from the next step — so a form is impossible to misconfigure. The result is plain data your renderer can walk in a single loop.
Features
- Type-safe by construction — the builder's methods are your model's keys; each field is set exactly once, with per-field types inferred.
- Framework-agnostic — the config is plain data, so it renders in React, Vue, Angular, Svelte, Solid, or the bare DOM.
- Zero dependencies — small, tree-shakeable ESM that ships its own type definitions.
- Smart field inference — text, number, email, password, date, select, radio, checkbox, file, nested groups, and arrays.
- Terse or explicit — concise control helpers (
text(),select(),array()) or plain node objects, your call. - First-class arrays — repeating groups with add/remove/replace/sort, file uploads, and multi-selects, each typed to the element.
- Works in plain JavaScript — full type-checking and autocomplete via
// @ts-check+ JSDoc, no build step required. - Immutable branching —
fork()for isolated form variants from a shared base.
Why formodel?
Most form code tangles three things together: the data shape, how each field should look, and how it gets rendered. formodel pulls them apart.
- One typed source of truth. Your form's structure and metadata live in a single object, derived straight from your data model. Change the model, and the types tell you what to update.
- Your renderer is just a loop. The output is plain data —
{ firstName: { type: 'text', label: 'First name' }, … }. Walk it and render however you like. The same config drives React today and vanilla JS tomorrow. - You can't misconfigure it. Configure a key and it disappears from the next step — so you can't forget a field or set it twice, and each field's node type is inferred (a
selectwantsoptions, an array wants an item config, and so on). - No lock-in, no weight. Pure TypeScript, zero dependencies, tree-shakeable.
Install
pnpm add formodel
# or: npm i formodel / yarn add formodelQuick start — build a typed form config
import { UiFormConfigBuilder } from 'formodel';
export const signupConfig = UiFormConfigBuilder.from({
firstName: '',
email: '',
role: '',
subscribe: false,
})
.firstName({ type: 'text', label: 'First name' })
.email({ type: 'email', label: 'Email' })
.role({
type: 'select',
label: 'Role',
options: [
{ value: 'admin', description: 'Admin' },
{ value: 'user', description: 'User' },
],
})
.subscribe({ type: 'checkbox', label: 'Subscribe to the newsletter', value: true });
// signupConfig is typed as UiFormGroupConfig<typeof model>When the last field is set you get the finished config back automatically — no .build(), no guesswork.
Less typing: control helpers
Rather not spell out { type, label } every time? Import the matching helper — text, textarea, number, email, password, phone, url, date, file, select, multiselect, radio, checkbox, array:
import { UiFormConfigBuilder, text, email, phone, number, select, checkbox } from 'formodel';
const config = UiFormConfigBuilder.from({ firstName: '', email: '', phone: '', age: 0, subscribe: false })
.firstName(text('First name', { placeholder: 'Jane' }))
.email(email('Email'))
.phone(phone('Phone'))
.age(number('Age', { min: 0, max: 120 }))
.subscribe(checkbox('Subscribe to the newsletter'));Each helper takes the label first and an optional second argument for the extras (placeholder, min / max, accept, …). select / multiselect / radio take their options, and array(itemConfig, { addable, removable }) builds a repeating group. They return the same typed nodes as the plain object form — use whichever reads better.
Custom field kinds
The built-in kinds cover ordinary fields. For a widget your renderer knows about — a device picker, a company-lookup field — customInputType<Value, Props>(kind) mints a typed helper used just like text()/select(). The node is value-matched: it only type-checks on a field whose value is assignable to Value, so you can't put a Device[] picker on a string field.
import { customInputType } from 'formodel';
// declare once: value type Device[], props { max?: number }
const devicePicker = customInputType<Device[], { max?: number }>('devicePicker');
const config = UiFormConfigBuilder.from({ devices: [] as Device[] })
.devices(devicePicker({ max: 5 })); // → { type: 'devicePicker', props: { max: 5 } }The node carries type (the kind your renderer resolves) and a typed props bag; a control with no config takes no props (customInputType<string>('nip') → nip()). It's the same plain-data output as every other node, so a renderer can map it like any control.
Render a form in React
The config is just an object keyed by field name, so a generic renderer is a map over its entries:
import { signupConfig } from './signup-config';
import type { UiFormControlNode } from 'formodel';
function Field({ name, node }: { name: string; node: UiFormControlNode }) {
if (node.type === 'select') {
return (
<label>
{node.label}
<select name={name}>
{node.options.map((o) => (
<option key={String(o.value)} value={String(o.value)}>
{o.description}
</option>
))}
</select>
</label>
);
}
if (node.type === 'textarea') {
return (
<label>
{node.label}
<textarea name={name} />
</label>
);
}
if (node.type === 'checkbox') {
return (
<label>
<input type="checkbox" name={name} /> {node.label}
</label>
);
}
// text, email, password, number, tel, url, date, file… map 1:1 to <input type>
return (
<label>
{node.label}
<input type={node.type} name={name} placeholder={node.placeholder} />
</label>
);
}
export function SignupForm() {
return (
<form>
{Object.entries(signupConfig).map(([name, node]) => (
<Field key={name} name={name} node={node} />
))}
</form>
);
}Because the control type values line up with HTML input types, the common fields need no special-casing at all.
Render a form in vanilla JavaScript
No framework, no build step — and still fully typed. Add // @ts-check and pull in formodel's types with a JSDoc import(), and your editor type-checks plain JavaScript:
// @ts-check
import { UiFormConfigBuilder } from 'formodel';
const config = UiFormConfigBuilder.from({ firstName: '', email: '' })
.firstName({ type: 'text', label: 'First name' })
.email({ type: 'email', label: 'Email' });
/**
* @param {string} name
* @param {import('formodel').UiFormControlNode} node
* @returns {HTMLLabelElement}
*/
function createField(name, node) {
const label = document.createElement('label');
label.textContent = node.label ?? name;
const input = document.createElement('input');
input.type = node.type; // 'text', 'email', … — checked against the node type
input.name = name;
label.append(input);
return label;
}
const form = document.createElement('form');
// `config` is inferred, so `node` is a UiFormControlNode here — no annotation needed
for (const [name, node] of Object.entries(config)) {
form.append(createField(name, node));
}
document.body.append(form);More runnable, type-checked samples (React, vanilla JS, arrays, fork) live in
examples/.
How fields map
- Scalar keys take a
UiFormControlNode—text,textarea,number,email,password,tel,url,select,date,radio,checkbox,file. - Array keys take an
ArrayFormControlNode<Element>, typed to the element. Arrays are first-class:- a repeating group (
type: 'array') with anitemConfigandaddable/removable/replaceable/sortableactions plusminItems/maxItems, - a multi-select (
type: 'select',multiple: true), - or a file upload (
type: 'file', withaccept/multiple/maxFiles) for attachment lists.
- a repeating group (
- Object keys take
(meta, childBuilder)and nest into a group. Datefields map to adatecontrol.nulland function fields are skipped — at runtime and in the type.
Branching with fork
Need a couple of variants from a shared base? fork gives you an isolated snapshot — branches never step on each other:
const base = UiFormConfigBuilder.from({ name: '', role: '' })
.name({ type: 'text', label: 'Name' });
const admin = UiFormConfigBuilder.fork(base).role({ type: 'text', label: 'Admin role' });
const guest = UiFormConfigBuilder.fork(base).role({ type: 'text', label: 'Guest role' });API
UiFormConfigBuilder.from(model)— start a builder whose methods aremodel's keys.UiFormConfigBuilder.fork(builder)— branch an in-progress builder into an isolated copy.- Types:
UiFormControlNode,ArrayFormControlNode,UiFormGroupConfig,UiFormGroupNode, and each control node (TextFormControlNode,SelectFormControlNode,FileFormControlNode, …).
Develop
pnpm install
pnpm test # runtime specs (Vitest)
pnpm test:types # type-level assertions (vitest --typecheck)
pnpm build # emit dist/ (ESM + .d.ts)
pnpm examples:check # type-check the examples against the public APILicense
0BSD dorian_dev