Skip to content

API Overview

SymbolPurposeExecution modeCommon gotcha
createForm()Create immutable form stateSyncinitialValues cannot contain mutable class instances
form.field()Select a top-level or object child fieldSyncArrays have no index field handles
form.validate()Validate complete valueAsyncHandle aborted separately
form.submit()Touch, validate, then invoke handlerAsyncConcurrent calls reject
form.reset()Restore or replace baselineSyncreset(next) makes next clean
form.subscribe()Observe form metadataSyncThrows after disposal
toFormData()Serialize values for multipart transportSyncFileList is transport-only
bindField()Bind one DOM elementSyncDoes not schedule validation
customValidator()Adapt a Spell schemaAsyncDoes not transform form.value
saveForm() / loadForm()Persist explicit Vault recordsAsyncFormDraftCodec owns record shape

Package Entry Point

ImportPurpose
@vielzeug/forgeCore form factory, serialization helper, types, and errors
@vielzeug/forge/dombindField() and DOM binding types
@vielzeug/forge/spellcustomValidator()
@vielzeug/forge/vaultsaveForm(), loadForm(), and FormDraftCodec

Core Functions

createForm(options)

ts
function createForm<TValues extends Record<string, unknown>>(options: FormOptions<TValues>): Form<TValues>;

Creates a form with immutable initial values and an optional full-form validator.

ParameterTypeDescription
options.initialValuesTValuesInitial value and reset baseline. Supports primitives, plain objects, arrays, File, and Blob.
options.validateFormValidator<TValues>Optional validator for the entire current value.
options.onSubscriberError(error: unknown) => voidOptional subscriber failure reporter.

Returns: Form<TValues>.

Example:

ts
import { createForm } from '@vielzeug/forge';

const form = createForm({ initialValues: { email: '' } });

toFormData(values)

ts
function toFormData(values: Record<string, unknown>): FormData;

Converts nested values into FormData with dot-separated object keys and repeated array keys.

Returns: a populated FormData instance.

Example:

ts
import { toFormData } from '@vielzeug/forge';

const body = toFormData({ profile: { email: 'ada@example.com' }, tags: ['typescript', 'forms'] });

Form Handles

Form<TValues>

createForm() returns this handle.

MemberSignatureDescription
valueReadonlyDeep<TValues>Current immutable value.
stateFormState<TValues>Submission, validation, touch, and error metadata.
field(key)Field<TValues[K]>Select a top-level field.
set(next)voidReplace the complete value or derive a replacement.
reset(next?)voidRestore baseline or make next the baseline.
validate(signal?)Promise<ValidationResult<TValues>>Run full-form validation.
submit(handler)Promise<SubmitResult<TResult, TValues>>Touch, validate, and invoke handler when valid.
subscribe(listener, options?)UnsubscribeObserve form state; throws after disposal.
dispose()voidAbort validation and clear subscribers.
disposedbooleanWhether the form has been disposed.
disposalSignalAbortSignalAborts on disposal.

Field<V>

form.field(key) and object-field .field(key) return this handle.

MemberSignatureDescription
valueReadonlyDeep<V>Current immutable branch value.
errorstring | undefinedCurrent field error.
dirtybooleanWhether branch differs from baseline.
touchedbooleanWhether field was touched.
field(key)Field<V[K]>Select child object field only.
set(next)voidReplace branch or derive a replacement.
reset()voidRestore exact baseline branch.
touch()voidMark field touched.
subscribe(listener, options?)UnsubscribeObserve field transitions; throws after disposal.

Validation Results

form.validate(signal?)

ts
function validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;

Runs the configured validator against the complete value. A newer validation aborts the older run.

Returns: ValidationResult<TValues>.

ts
const result = await form.validate();

if (result.status === 'invalid') console.log(result.errors, result.formError);

form.submit(handler)

ts
function submit<TResult>(handler: (values: ReadonlyDeep<TValues>) => MaybePromise<TResult>): Promise<SubmitResult<TResult, TValues>>;

Touches all fields, validates once, and invokes handler when validation is valid.

Returns: SubmitResult<TResult, TValues>. Handler failures reject normally.

ts
const result = await form.submit((value) => Promise.resolve(value));

Adapters

bindField(element, field, options)

ts
function bindField<Element extends HTMLElement, V>(
  element: Element,
  field: Field<V>,
  options: FieldBindingOptions<Element, V>,
): Unsubscribe;

Binds one field to one element, marks it touched on blur, suppresses writeback from its own input event, and returns teardown.

Example:

ts
import { bindField } from '@vielzeug/forge/dom';

const stop = bindField(input, form.field('email'), {
  read: (element) => element.value,
  write: (element, value) => {
    element.value = value;
  },
});

customValidator(schema)

ts
function customValidator<TValues extends Record<string, unknown>>(
  schema: Schema<unknown, TValues>,
): FormValidator<TValues>;

Adapts a Spell schema. Every failing union maps its closest branch while preserving unrelated errors. Array item issues map to the parent array field; duplicate paths retain the first message.

Example:

ts
import { customValidator } from '@vielzeug/forge/spell';
import { s } from '@vielzeug/spell';

const Profile = s.object({ email: s.string().email() });
const form = createForm({ initialValues: { email: '' }, validate: customValidator(Profile) });

saveForm() and loadForm()

ts
function saveForm<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string>(
  form: Form<TValues>, adapter: VaultStore<S>, table: K, codec: FormDraftCodec<TValues, S, K>,
): Promise<void>;

function loadForm<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string>(
  form: Form<TValues>, adapter: VaultStore<S>, table: K, key: KeyOf<S, K>, codec: FormDraftCodec<TValues, S, K>,
): Promise<boolean>;

Persists or restores a codec-defined Vault record. loadForm() calls form.reset() when the codec decodes a record.

Returns: loadForm() returns false for a missing or rejected record.

Types

ts
type Unsubscribe = () => void;
type MaybePromise<T> = T | PromiseLike<T>;
type ReadonlyDeep<T> = T extends (...args: never[]) => unknown
  ? T
  : T extends readonly (infer Item)[]
    ? readonly ReadonlyDeep<Item>[]
    : T extends Record<string, unknown>
      ? { readonly [K in keyof T]: ReadonlyDeep<T[K]> }
      : T;

type FormErrors<T> = T extends readonly unknown[]
  ? string
  : T extends Record<string, unknown>
    ? string | { readonly [K in keyof T]?: FormErrors<T[K]> }
    : string;

type ValidationErrors<TValues extends Record<string, unknown>> = Readonly<{
  fields?: FormErrors<TValues>;
  formError?: string;
}>;

type FormValidator<TValues extends Record<string, unknown>> = (
  values: ReadonlyDeep<TValues>, signal: AbortSignal,
) => MaybePromise<ValidationErrors<TValues> | undefined>;

type FormOptions<TValues extends Record<string, unknown>> = Readonly<{
  initialValues: TValues;
  onSubscriberError?: (error: unknown) => void;
  validate?: FormValidator<TValues>;
}>;

type SubscribeOptions = Readonly<{ immediate?: boolean }>;

type FieldState<V> = Readonly<{
  dirty: boolean;
  error: string | undefined;
  touched: boolean;
  value: ReadonlyDeep<V>;
}>;

type FormState<TValues extends Record<string, unknown> = Record<string, unknown>> = Readonly<{
  error: string | undefined;
  errors: FormErrors<TValues> | undefined;
  submitCount: number;
  submitting: boolean;
  touched: boolean;
  valid: boolean;
  validating: boolean;
}>;

type ValidationResult<TValues extends Record<string, unknown> = Record<string, unknown>> =
  | Readonly<{ status: 'aborted' }>
  | Readonly<{ status: 'valid' }>
  | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; status: 'invalid' }>;

type SubmitResult<TResult = void, TValues extends Record<string, unknown> = Record<string, unknown>> =
  | Readonly<{ ok: true; value: TResult }>
  | Readonly<{ ok: false; type: 'aborted' }>
  | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; ok: false; type: 'validation' }>;
ts
type Field<V> = {
  readonly dirty: boolean;
  readonly error: string | undefined;
  readonly touched: boolean;
  readonly value: ReadonlyDeep<V>;
  field<K extends keyof NonNullable<V> & string>(key: K): Field<NonNullable<V>[K]>;
  reset(): void;
  set(next: V | ((previous: ReadonlyDeep<V>) => V)): void;
  subscribe(listener: (state: FieldState<V>) => void, options?: SubscribeOptions): Unsubscribe;
  touch(): void;
};

type Form<TValues extends Record<string, unknown>> = {
  [Symbol.dispose](): void;
  readonly disposalSignal: AbortSignal;
  readonly disposed: boolean;
  readonly state: FormState<TValues>;
  readonly value: ReadonlyDeep<TValues>;
  dispose(): void;
  field<K extends keyof TValues & string>(key: K): Field<TValues[K]>;
  reset(next?: TValues): void;
  set(next: TValues | ((previous: ReadonlyDeep<TValues>) => TValues)): void;
  submit<TResult = void>(handler: (values: ReadonlyDeep<TValues>) => MaybePromise<TResult>): Promise<SubmitResult<TResult, TValues>>;
  subscribe(listener: (state: FormState<TValues>) => void, options?: SubscribeOptions): Unsubscribe;
  validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;
};

type FieldBindingOptions<Element extends HTMLElement, V> = Readonly<{
  event?: keyof HTMLElementEventMap;
  read(element: Element): V;
  write?: (element: Element, value: ReadonlyDeep<V>) => void;
}>;

type FormDraftCodec<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string> = Readonly<{
  fromRecord(record: RecordOf<S, K>): TValues | undefined;
  toRecord(values: ReadonlyDeep<TValues>): RecordOf<S, K>;
}>;

Errors

ErrorTriggerNotable properties
ForgeErrorBase Forge errorForgeError.is(error) narrows unknown values.
ForgeConfigErrorUnsafe key or unsupported form valueExtends ForgeError.
ForgeDisposedErrorOperation or subscription after disposalMessage names the attempted operation.
ForgeSubmitErrorConcurrent submit() callExtends ForgeError.
ForgeValidationErrorValidator throws unexpectedlyPreserves original error as cause.