Form object

Read and write an HTMLFormElement as a nested plain object via field names.

FormObject turns an HTMLFormElement into a structured plain object — and writes one back — using the control name to shape the result. It is a static snapshot, like new FormData(), with no reactivity or dependencies. Type into the form below and watch the object update.

form.tsmain.tsxstyles.ts
import { signal } from "elements-kit/signals";
import { FormObject } from "elements-kit/utilities/form-object";

/** JSON of the form's current values. */
export const snapshot = signal("{}");

export function update(form: HTMLFormElement) {
  snapshot(JSON.stringify(new FormObject(form).toObject(), null, 2));
}

export function prefill(form: HTMLFormElement) {
  new FormObject(form).fromObject({
    user: { name: "Sara", email: "sara@example.com" },
    address: { city: "Paris" },
    interests: ["music", "travel"],
    newsletter: "yes",
  });
  update(form);
}

Field names shape the object

The name attribute is a path. Dots nest objects, numeric segments build arrays, and a trailing [] marks an append array.

import { FormObject } from "elements-kit/utilities/form-object";

// <input name="user.name">        → { user: { name } }
// <input name="user.address.city">→ { user: { address: { city } } }
// <input name="tags.0">           → { tags: [ "…" ] }   (explicit index)
// <input name="colors[]">         → { colors: [ "…" ] } (append, one per control)
const data = new FormObject(form).toObject();

A trailing [] always produces an array — a single colors[] control gives ["x"], and a group with none checked still serializes to []. Use it whenever a field is conceptually a list.

Reading a form

toObject() walks the form's named controls and returns the nested object. It mirrors native form submission: disabled and unchecked controls are dropped, <select multiple> yields an array, and <input type="file"> yields File objects. toJSON() is an alias, so JSON.stringify(instance) works.

const form = document.querySelector("form")!;
const data = new FormObject(form).toObject();

Bare repeated names: last value wins

When several controls share a bare name (no []), the last value wins — matching form-data-json. So the <input type="hidden" name="x" value="0"> + <input type="checkbox" name="x" value="1"> idiom yields "1" when checked and "0" when unchecked. Reach for name="x[]" when you actually want both values in an array.

Writing back

fromObject(data) assigns values from a nested object onto matching controls — setting .value, checking boxes and radios, and selecting options. Missing paths are left untouched. clear() resets every named control. Both return this for chaining.

new FormObject(form).fromObject({
  user: { name: "Wael" },
  colors: ["red", "blue"], // checks the matching colors[] boxes
});

Transforms

Extraction runs each field through a pipeline of FormFieldTransforms. Each transform receives the field ({ control, name, value, checked? }) and returns it, a modified copy, or null to drop it. The default pipeline skips disabled controls, buttons, and unchecked boxes:

import {
  FormObject,
  defaultTransforms,
  uncheckedAs,
  skipEmpty,
} from "elements-kit/utilities/form-object";

// Replace the defaults — include unchecked checkboxes as `false`
new FormObject(form, {
  transforms: [...defaultTransforms.slice(0, 2), uncheckedAs(false)],
}).toObject();

// Extend — drop empty fields and trim strings
new FormObject(form, {
  transforms: [
    ...defaultTransforms,
    skipEmpty,
    (f) => ({ ...f, value: typeof f.value === "string" ? f.value.trim() : f.value }),
  ],
}).toObject();

Built-ins: skipDisabled, skipButtons, dropUnchecked (the defaults), plus uncheckedAs(value) and skipEmpty. Passing transforms replaces the defaults — spread ...defaultTransforms to keep them.

An empty pipeline keeps everything

transforms: [] removes all filtering, so disabled controls, buttons, and unchecked checkbox values are all emitted. Inspect field.checked in a custom transform when you need the checkbox state.

Field names that walk an object's prototype (__proto__, prototype, constructor) are ignored, so a malicious name cannot pollute Object.prototype.

See also