The Type-Level Form Engine
Advanced · 100 min build · 🏆 milestone project
You are ready to build a small type-level form engine, a schema-driven form core that keeps field configuration, form values, rendering, and runtime validation in sync.
The goal is not to build a browser UI. The playground prints a text rendering and parses fixed sample submissions so every run is repeatable. The real win is the shape of the system: edit the field config once, and the value type, input names, error names, renderer checklist, and boundary parser all follow.
What you'll build
You will build one form-engine workspace with:
- Field configuration checked with
satisfies. - Value types derived from field config with mapped types.
- Input and error names derived with template literal types.
- Exhaustive rendering for every field kind.
- A live mini-schema boundary that parses
unknownform input into typed values. - Static production-library Zod code that shows the same boundary shape without pretending packages exist in the playground.
A form engine is a boundary tool. Outside data arrives as strings and unknown objects. Inside the app, callers should receive either typed values or typed errors.
→ Rendered form:
→ - Full name: <input name="fullNameInput" type="text">
→ - Email: <input name="emailInput" type="text">
→ - Seat count: <input name="seatsInput" type="number">
→ - Plan: <select name="planInput">free | pro | enterprise</select>
→ Input names: fullNameInput, emailInput, seatsInput, planInput
→ Parsed: Ada Lovelace chose pro for 3 seats.
→ Errors:
→ - fullNameError: Full name is required.
→ - emailError: Email must be at least 5 characters.
→ - seatsError: Seat count must be at least 1.
→ - planError: Plan must be one of free, pro, enterprise.
The two assertions in parseForm are intentionally narrow: after every configured field has parsed or reported an error, the checker still cannot see that the dynamic values object has exactly FormValues<Config>. That is the honest 13.3 use of as: a receipt for checks that just ran, not a costume at the boundary.
Checkpoint 1: define the field config
Start with signupFields. It is a normal runtime object, checked against FieldConfigMap with satisfies.
Acceptance criteria:
- The field map has
text,number, andselectfields. signupFieldsusesas const satisfies FieldConfigMap.- Adding a field with a misspelled
kindfails before the program runs. - The first run prints four rendered fields.
Checkpoint 2: derive value, input, and error types
FormValues<Config> maps over field names and chooses the value type from each field kind. InputNames<Config> and ErrorBag<Config> use template literal types to derive names such as fullNameInput and fullNameError.
Acceptance criteria:
SignupValues["seats"]isnumber.SignupValues["plan"]is"free" | "pro" | "enterprise".inputNamesmust include exactly the*Inputkeys derived from the config.- Uncommenting
brokenDefaultsfails becauseseatsmust be a number.
Checkpoint 3: validate at the boundary
The live parser follows the Section 13 rule: outside input is unknown, then parseForm returns typed values or typed field errors. The mini-schema is deliberately small and package-free so it can run in the playground.
Acceptance criteria:
goodSubmissionparses and prints a typedSignupValuessummary.badSubmissionreturnsfullNameError,emailError,seatsError, andplanError.- No code reads fields from
unknownbeforeobjectFieldschecks the object boundary. - The only brand-style assertions are after validation has already run.
Checkpoint 4: keep rendering exhaustive
Rendering uses a discriminated union and a never checklist. If the field union grows, the renderer must grow too.
This static fence intentionally adds a checkbox field kind without rendering it:
type TextField = { kind: "text"; label: string; required: boolean };
type NumberField = { kind: "number"; label: string; required: boolean };
type SelectField = { kind: "select"; label: string; required: boolean; options: readonly string[] };
type CheckboxField = { kind: "checkbox"; label: string; required: boolean };
type FieldConfig = TextField | NumberField | SelectField | CheckboxField;
function renderField(field: FieldConfig): string {
switch (field.kind) {
case "text":
return field.label;
case "number":
return field.label;
case "select":
return field.options.join(", ");
default: {
const unhandled: never = field;
return unhandled;
}
}
}→ playground.ts:17:13 - error TS2322: Type 'CheckboxField' is not assignable to type 'never'.
Acceptance criteria:
renderFieldhas onecaseper field kind.- The
defaultbranch assigns tonever. - Adding a new field kind without a renderer case produces a checker error.
Static production shape: real Zod
The playground cannot import packages. In a real app, the boundary parser could be powered by Zod while the same config-derived types drive the rest of the form engine.
The production-library shape, shown as read-only code because Zod is not installed in the playground:
import * as z from "zod";
const SignupSchema = z.object({
fullName: z.string(),
email: z.string(),
seats: z.string().transform((value) => Number(value)),
plan: z.union([z.literal("free"), z.literal("pro"), z.literal("enterprise")]),
});
type SignupValues = z.infer<typeof SignupSchema>;
function parseSignup(input: unknown) {
return SignupSchema.safeParse(input);
}Acceptance criteria:
- Real Zod examples stay as read-only reference code.
- The live playground keeps using the mini-schema/parser pattern from 13.2.
- Boundary code returns parsed data or a typed failure; application code uses only parsed values.
Stretch goals
- Add a
checkboxfield kind and updateFieldValue,parseField, andrenderField. - Add a branded
EmailAddressconstructor after the text parser proves the field is a string. - Add a
defaultsobject checked withsatisfies Partial<SignupValues>. - Change the config and confirm the derived input/error names update without hand-written unions.
How to get unstuck
If the checker complains inside the mapped types, read them right to left: field name in, derived property out. If the parser complains, remember the Section 13 boundary rule: unknown cannot be trusted until a runtime check returns a receipt. If rendering breaks after a new field kind, the never branch is doing its job: add the missing case.