Type safety
A hand-authored FormConfig is just data — TypeScript has no way to know that a { type: "email", name: "email" } field turns into a string, or that a group turns into an array of rows. defineForm closes that gap: wrap a config in it and InferValues<typeof config> reads the literal shape back out as a real submit-payload type — autocomplete on values, a typed onSubmit, a typed server-side result. Zero runtime cost; defineForm returns its argument unchanged.
Wrap your config in defineForm
defineForm is an identity function with one job: its type parameter is declared const, so TypeScript infers the literal type of the object you pass in — every field's name and type as literal strings, not widened to string. That literal type is exactly what InferValues needs to read.
// config.ts
import { defineForm } from "@/form-builder";
export const signupConfig = defineForm({
id: "signup",
fields: [
{ type: "email", name: "email", required: true },
{ type: "number", name: "age", required: true },
{
// Only present once "email" is a valid address — see
// "Conditional fields are optional keys" below.
type: "text",
name: "referralCode",
visibleWhen: { field: "email", isValid: true },
},
{ type: "submit", name: "submit", text: "Sign up" },
],
});submit and static fields are layout only — a submit field in the example above never becomes a key in Values. Everything else does.
import type { InferValues } from "@/form-builder";
import { signupConfig } from "./config";
type Values = InferValues<typeof signupConfig>;
// Hovering `Values` in your editor shows:
// {
// email: string;
// age: number;
// referralCode?: string;
// }Value-type mapping
FieldValue maps a single field's type (and, for a few types, a discriminating prop like multiple or range) to its value type. InferValues walks every field in config.fields and keys the result by name.
| Field type(s) | Value type | Notes |
|---|---|---|
| text, email, textarea, password, masked, otp, phone, country, radio, segmented, signature, date, time | string | date here means the default (non-range) shape — see the date row below for range: true. |
| number, slider, rating | number | |
| checkbox, switch | boolean | the field has no options array — a single on/off toggle. |
| checkbox, switch | string[] | the field has an options array — a checkbox group or multi-switch. |
| select | string | default (single-select). |
| select | string[] | multiple: true. |
| date | [string, string] | range: true — a [from, to] tuple of yyyy-MM-dd strings. |
| file | File | File[] | default (single). |
| file | File[] | multiple: true. |
| group | Array<row> | one inferred object per row, using this same table recursively over the group's own fields. |
| hidden | typeof field.value | carries the field's own value literal through unchanged. |
| custom (registerField) | unknown | the engine can't know a registered type's shape — see “Custom field types” below. |
| static, submit | — (no key) | layout-only fields; never part of the payload. |
Optional keys track visibility, not required
A key is optional in the inferred payload when its field has visibleWhen or enabledWhenVerified — because parseSubmissionstrips a field's value when it isn't visible, so the key may simply not be there. This is unrelated to the field's own required flag. required only governs whether Zod accepts an empty value for a visible field — it never removes the key from the payload, so a required: false field with no condition is still a required key in Values (its value can be an empty string, but the key is always present).
const cfg = defineForm({
id: "f",
fields: [
{ type: "text", name: "always", required: true },
{
type: "text",
name: "maybe",
required: true,
visibleWhen: { field: "always", equals: "x" },
},
],
});
type Values = InferValues<typeof cfg>;
// { always: string; maybe?: string }"Is this key present in the payload?" (conditional visibility, tracked by InferValues) and "must this field have a value when it's visible?" (required, enforced by Zod at runtime) are independent. A field can be required: true and still an optional key, and required: false and still a required key.
Custom field types escape to unknown
A field type registered with registerField isn't one of the built-in type strings FieldValue matches against, so it falls through every branch and infers as unknown. This mirrors the engine's own honesty about it at runtime: parseSubmissioncan't build a Zod schema for a shape it doesn't know either, so a custom field's name always lands in result.unvalidated instead of being checked.
The type system and the runtime agree: narrow a custom field's value yourself. See Custom (registered) field types in Server-side validation for the recipe (a few lines of Zod over result.values[name]).
Known gap: step-level visibility
InferValues only looks at a field's own visibleWhen/enabledWhenVerified. A field with neither, but declared inside a wizard step whose own visibleWhen hides the whole step, can still be stripped by parseSubmission at runtime — yet InferValues currently infers it as a required (non-optional) key. The runtime behavior is correct; only the inferred type is optimistic here. Treat a key from a field inside a conditional step as possibly absent even though its type says otherwise, until this is widened to walk config.steps too.
See form-builder/core/defineForm.ts and form-builder/core/inferValues.ts for the full mapping (pinned by expectTypeOf type tests, one per table row). For the server-side half of this — the typed parseSubmission result and the createFormAction wire — see Submit to backend. Name-reference constraints (typo-checking a Condition.field or dependsOn target against real field names) are a deferred follow-up — validateFormConfig catches those at dev-time meanwhile, per Conditions.