←  Back to blog

Type-safe forms without the boilerplate

Mar 22, 2026 · 2 min read architecture tooling

Forms are where type safety usually goes to die. You define a TypeScript interface, then a separate validation function, then wire up state by hand — three sources of truth that drift apart the moment requirements change. The fix is to make one of them generate the other two.

One schema to rule them

A schema library like Zod lets you describe the shape once and derive everything else from it. The type comes for free via inference; the validator is the schema.

import { z } from "zod";

const SignupSchema = z.object({
    email: z.string().email(),
    password: z.string().min(8, "At least 8 characters"),
    plan: z.enum(["free", "pro"]),
});

// The type is derived — never written by hand, never out of date.
type Signup = z.infer<typeof SignupSchema>;

Change the schema and the type updates, the validation updates, and TypeScript flags every call site that no longer fits. That last part is the whole point.

Validate at the boundary

The schema earns its keep at the edges of your system: the form submit and the API handler. Parse once, and everything downstream is typed and trusted.

function onSubmit(raw: unknown) {
    const result = SignupSchema.safeParse(raw);
    if (!result.success) {
        return showErrors(result.error.flatten().fieldErrors);
    }
    // result.data is fully typed as Signup from here on.
    return api.signup(result.data);
}

Parse, don’t validate. Once data has passed the schema it changes type, not just passes a check — so the compiler stops you from using it as anything looser downstream.

Scaling to wizards

The pattern that surprised me is how well this composes for multi-step forms. Each step is its own schema; the wizard’s type is their intersection. You validate each step on its way out and merge the parsed results.

const Step1 = z.object({ email: z.string().email() });
const Step2 = z.object({ company: z.string().min(1) });

// The finished payload is the intersection of every step.
type Wizard = z.infer<typeof Step1> & z.infer<typeof Step2>;

No giant all-or-nothing schema, no half-typed intermediate state — each step is independently valid and the final type assembles itself.

What you stop writing

The boilerplate that disappears: hand-written interfaces, hand-written validators, and the mapping code that kept them in sync. What’s left is the schema and the rendering. Three jobs, one source of truth — and a compiler that complains the instant they’d have drifted apart.

~
Husnul Aman
Full-Stack Developer
Email GitHub