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 unknown form 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.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ 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, and select fields.
  • signupFields uses as const satisfies FieldConfigMap.
  • Adding a field with a misspelled kind fails 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"] is number.
  • SignupValues["plan"] is "free" | "pro" | "enterprise".
  • inputNames must include exactly the *Input keys derived from the config.
  • Uncommenting brokenDefaults fails because seats must 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:

  • goodSubmission parses and prints a typed SignupValues summary.
  • badSubmission returns fullNameError, emailError, seatsError, and planError.
  • No code reads fields from unknown before objectFields checks 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:

  • renderField has one case per field kind.
  • The default branch assigns to never.
  • 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 checkbox field kind and update FieldValue, parseField, and renderField.
  • Add a branded EmailAddress constructor after the text parser proves the field is a string.
  • Add a defaults object checked with satisfies 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.

+50 XP on completion