npm.io
0.2.0 • Published 4d ago

formodel

Licence
0BSD
Version
0.2.0
Deps
0
Size
56 kB
Vulns
0
Weekly
0

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 branchingfork() 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 select wants options, 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 formodel

Quick 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 UiFormControlNodetext, 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 an itemConfig and addable / removable / replaceable / sortable actions plus minItems / maxItems,
    • a multi-select (type: 'select', multiple: true),
    • or a file upload (type: 'file', with accept / multiple / maxFiles) for attachment lists.
  • Object keys take (meta, childBuilder) and nest into a group.
  • Date fields map to a date control.
  • null and 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 are model'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 API

License

0BSD dorian_dev

Keywords