Type-Safely Validating Form Data with Zod and Friends
AI generated
<T>
type
TypeScript · Zod · Valibot · Form Validation
Type-Safely Validating Form Data with Zod and Friends
How schema validation unifies runtime safety and TypeScript types

TypeScript types only exist at compile time and do nothing to protect form data from messy user input, which always arrives at runtime as unknown strings. This article shows how schema validation libraries like Zod and Valibot derive both runtime checks and TypeScript types from a single source, applied hands on to checkout forms, native FormData handling, typed API layers, and clear field errors in the user interface.

12 min. read Zod · Valibot · z.infer Forms · FormData · API Validation

1. Why TypeScript types are worthless at runtime

TypeScript types exist exclusively at compile time. The compiler checks assignments, function calls, and interfaces against a static type system, but strips this information entirely from the emitted JavaScript during the build, a process known as type erasure. At runtime there is no interface, no type alias, and no generic signature left, only plain JavaScript with zero type checking. Anyone who believes an interface ContactForm prevents anything at runtime is confusing a pure developer aid with an actual safeguard.

Form data is a particularly critical case because it structurally always enters the application as unknown or string, whether it comes from an <input> element, a FormData object, a JSON.parse() call, or an external API. A type cast with as ContactForm changes nothing about that, it is merely a claim made to the compiler, not a check. Only an actual runtime validation decides whether the claim holds true before faulty data flows further into business logic, a database, or downstream API calls.

2. Schema-first: deriving types from runtime validation

Schema validation libraries such as Zod and Valibot solve this by flipping the relationship between type and validation. Instead of writing a TypeScript interface first and then manually maintaining a separate runtime check, you define a schema as the single source of truth, and the TypeScript type is derived from it automatically. In Zod, z.infer<typeof schema> handles this derivation; in Valibot it's v.InferOutput<typeof schema>. Both patterns structurally prevent the type and the validation logic from drifting apart.

The practical payoff shows up in maintainability: if a field in the schema changes, say a required field becomes optional, the derived type updates automatically, and the compiler flags every place in the code that is no longer compatible with the old assumption. A hand-maintained interface next to a separate validation function offers no such guarantee, there the type and the check can silently drift apart for an arbitrarily long time, until a runtime error exposes the discrepancy.

3. A Zod schema for a checkout form in practice

A realistic example is a checkout form with nested address objects and a conditional rule: the billing address is only required if it differs from the shipping address. Zod models nested structures via z.object() inside another z.object() and allows additional validation logic beyond simple field rules through .refine(), including a path so the error attaches to the correct field.

The key difference is between .parse(), which throws an exception on invalid data, and .safeParse(), which instead returns a result object with a success flag. For forms in a UI, safeParse is almost always the right choice, since validation failures are a normal, expected state and shouldn't be handled with try/catch.


import { z } from "zod";

// Nested address schema reused inside the checkout schema
const addressSchema = z.object({
  street: z.string().min(3, "Street is too short"),
  postalCode: z.string().regex(/^\d{5}$/, "Postal code must have 5 digits"),
  city: z.string().min(2),
  country: z.enum(["DE", "AT", "CH"]),
});

export const checkoutFormSchema = z
  .object({
    email: z.string().email("Invalid email address"),
    firstName: z.string().min(1, "First name is required"),
    lastName: z.string().min(1, "Last name is required"),
    shippingAddress: addressSchema,
    billingAddress: addressSchema.optional(),
    useSameAddress: z.boolean().default(true),
    newsletter: z.boolean().default(false),
  })
  // Refinement: billingAddress is required when useSameAddress is false
  .refine(
    (data) => data.useSameAddress || data.billingAddress !== undefined,
    {
      message: "Billing address is required when it differs",
      path: ["billingAddress"],
    }
  );

// The TypeScript type is derived from the schema, never written by hand
export type CheckoutFormData = z.infer<typeof checkoutFormSchema>;

// Runtime parsing: safeParse returns a result object instead of throwing
const result = checkoutFormSchema.safeParse(rawFormInput);
if (!result.success) {
  console.error(result.error.flatten().fieldErrors);
}

4. Valibot as a lighter-weight alternative to Zod

Valibot follows the same schema-first principle as Zod but is built from the ground up for tree-shaking. Instead of a monolithic z object API with tightly coupled methods, Valibot imports individual, independent functions like minLength() or email() through a functional pipe() system. Bundlers can therefore consistently drop every validator that a project doesn't actually use, whereas Zod's core class lands in the bundle as one connected block, regardless of how many of its methods you actually call.

In numbers, that often means the difference between a few kilobytes with Valibot and a noticeably larger, barely reducible footprint with Zod for a typical form schema. For admin backends or internal tools where bundle size is a secondary concern, Zod often remains the more pragmatic choice thanks to its more mature ecosystem integration and more elaborate error messages. For public, performance-critical storefronts, especially in a Hyvä context with a minimal JavaScript footprint, Valibot is the more consistent decision.


import * as v from "valibot";

// Same nested address shape, expressed with Valibot's functional pipe API
const AddressSchema = v.object({
  street: v.pipe(v.string(), v.minLength(3, "Street is too short")),
  postalCode: v.pipe(v.string(), v.regex(/^\d{5}$/, "Postal code must have 5 digits")),
  city: v.pipe(v.string(), v.minLength(2)),
  country: v.picklist(["DE", "AT", "CH"]),
});

export const CheckoutFormSchema = v.object({
  email: v.pipe(v.string(), v.email("Invalid email address")),
  firstName: v.pipe(v.string(), v.minLength(1, "First name is required")),
  lastName: v.pipe(v.string(), v.minLength(1, "Last name is required")),
  shippingAddress: AddressSchema,
  billingAddress: v.optional(AddressSchema),
  useSameAddress: v.boolean(),
  newsletter: v.optional(v.boolean(), false),
});

// Type inference works the same way as with Zod's z.infer
export type CheckoutFormData = v.InferOutput<typeof CheckoutFormSchema>;

// Only the validator functions actually imported end up in the bundle
const result = v.safeParse(CheckoutFormSchema, rawFormInput);

5. Connecting validation to native HTML forms and FormData

Native HTML forms deliver their data through the FormData API as a collection of string and file entries, regardless of what data type a field actually represents. Checkboxes submit either the string "on" or no entry at all, never a boolean. This raw form must be converted into a plain object before handing it to a schema, and this is exactly the point where coercion rules for checkboxes, numbers, and date values should be applied explicitly, instead of silently leaving it to the schema.

The advantage of this approach over a controlled React or Alpine.js form binding with useState per field: the native form stays the single source of current state, there is no synchronization logic between the DOM and JavaScript state, and validation only kicks in at submit time against a complete, consistent snapshot of the input. For simple forms without keystroke-by-keystroke live validation, this is often the more robust and lower-maintenance solution.


// Convert native FormData into a plain object before validating
function formDataToObject(formData: FormData): Record<string, unknown> {
  const entries: Record<string, unknown> = {};
  for (const [key, value] of formData.entries()) {
    entries[key] = value;
  }
  return entries;
}

async function handleCheckoutSubmit(event: SubmitEvent): Promise<void> {
  event.preventDefault();
  const form = event.target as HTMLFormElement;
  const formData = new FormData(form);

  // Checkbox fields arrive as "on" / missing, so coerce them explicitly
  const raw = {
    ...formDataToObject(formData),
    useSameAddress: formData.get("useSameAddress") === "on",
    newsletter: formData.get("newsletter") === "on",
  };

  const result = checkoutFormSchema.safeParse(raw);
  if (!result.success) {
    renderFieldErrors(result.error.flatten().fieldErrors);
    return;
  }

  // result.data is now fully typed as CheckoutFormData, not raw FormData
  await submitCheckout(result.data);
}

6. A typed API layer: validating requests and responses

Schema validation doesn't stop at the form. The same uncertainty that applies to user input applies symmetrically to API responses: response.json() always returns type any, a bare claim with zero checking. A backend deploy, an API version change, or a simple server-side bug can return a structure that deviates from the expected type at any time, without TypeScript ever noticing.

The consistent solution is to define response schemas the same way as form schemas and run every API response through .parse() before further processing. That one extra call turns unknown network data into a reliably typed value and reliably surfaces it when the frontend and backend drift apart in their contract, instead of the error only appearing deep inside the application as a cryptic undefined is not a function error.


import { z } from "zod";

// Schema for the API response, decoupled from the internal domain model
const orderConfirmationSchema = z.object({
  orderId: z.string().uuid(),
  status: z.enum(["pending", "confirmed", "failed"]),
  estimatedDelivery: z.string().datetime(),
  total: z.object({
    amount: z.number().positive(),
    currency: z.literal("EUR"),
  }),
});

type OrderConfirmation = z.infer<typeof orderConfirmationSchema>;

async function submitCheckout(
  data: CheckoutFormData
): Promise<OrderConfirmation> {
  const response = await fetch("/api/checkout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    throw new Error(`Checkout failed with status ${response.status}`);
  }

  // Parse the untrusted JSON payload through the schema, not just a cast
  const json: unknown = await response.json();
  return orderConfirmationSchema.parse(json);
}

7. Error messages and typed field errors for the UI

A raw ZodError object is awkward to work with in a UI, because errors default to a flat list with path information. error.flatten().fieldErrors turns that list into an object with an array of error messages per field name, exactly the shape that binds directly to individual form fields. For nested objects like the address in the checkout example, error.format() is worth using as well, since it preserves the schema's nested structure instead of flattening it to a single level.

For reusable UI components, a generic FieldErrors<T> type pays off, linking the keys of the form type to optional error arrays. A FormField component can then expect this type as a prop and render not just visual errors but also screen-reader-accessible ones via aria-invalid and role="alert", without the component itself needing to know anything about Zod or Valibot.


import type { z } from "zod";

// Flattened, field-keyed error shape suitable for direct UI binding
type FieldErrors<T extends Record<string, unknown>> = Partial<
  Record<keyof T, string[]>
>;

function mapZodErrors<T extends Record<string, unknown>>(
  error: z.ZodError<T>
): FieldErrors<T> {
  return error.flatten().fieldErrors as FieldErrors<T>;
}

function FormField({
  label,
  name,
  errors,
}: {
  label: string;
  name: string;
  errors: FieldErrors<CheckoutFormData>;
}) {
  const fieldErrors = errors[name as keyof CheckoutFormData];

  return (
    <div>
      <label htmlFor={name}>{label}</label>
      <input id={name} name={name} aria-invalid={Boolean(fieldErrors)} />
      {fieldErrors?.map((message) => (
        <p key={message} role="alert" className="text-red-600 text-sm">
          {message}
        </p>
      ))}
    </div>
  );
}

8. Custom validation rules and cross-field validation

Many real-world form rules cannot be reduced to a single field. The classic example is password confirmation, where two fields must match; another is a conditionally required field that is only mandatory under certain circumstances, like the billing address in the checkout example. Zod covers both cases via .refine() on the entire object, where the callback function has access to all fields at once, rather than checking a single field in isolation.

For several independent cross-field rules within the same schema, .superRefine() is often the better choice, since it lets you issue multiple addIssue() calls with their own path through a ctx parameter, instead of chaining several .refine() calls that can influence each other's error order. It's important to deliberately separate cross-field rules from simple field rules, so a schema stays readable and doesn't turn into an opaque chain of conditions nobody fully understands anymore.

9. Manual vs. schema-derived validation compared

The difference between manual, untyped validation and schema-derived, type-safe validation shows up most clearly in the direct comparison of day-to-day development practice.

Aspect Manual / untyped Schema-derived / type-safe
Type source Interface hand-maintained separately Type derived from the schema via z.infer
Runtime checking If chains, easily forgotten or inconsistent Central schema, defined in one place
Consistency Type and check silently drift apart Compiler enforces consistency on change
FormData handling Manual casts like "as ContactForm" safeParse returns a genuinely checked type
API responses response.json() taken as any, unchecked Response schema surfaces contract breaks instantly
Error messages Inconsistent, hardcoded strings Structured, field-scoped error objects
Bundle cost No added dependency A few KB, minimal and tree-shakeable with Valibot

In practice, schema-derived validation pays off especially as forms grow and more team members are involved, because the compiler immediately surfaces every inconsistency between type and actual check, instead of letting it surface only at runtime in front of a customer.

Mironsoft

Type-safe forms, schema validation, and API integration for Magento and Hyvä

Ready to make your forms and APIs type-safe?

We build Zod or Valibot schemas for your forms and API layers, connect them to native FormData flows, and ship typed field errors straight into your user interface.

Schema audit

Checking existing forms and API contracts for type safety

Zod / Valibot setup

Introducing schemas with z.infer as the single source of truth

API integration

Typed requests and responses in your fetch layer

10. Summary

Type-safely validating form data solves a structural problem in TypeScript: types vanish entirely at runtime, while form data, FormData objects, and API responses always enter the application as unknown or string. Schema validation libraries like Zod and Valibot fix this by deriving the TypeScript type directly from the runtime schema, so the type and the check can never drift apart. A Zod schema with nested objects, refinements, and z.infer fully covers complex checkout forms, while Valibot's functional, tree-shakeable API is often the lighter choice for performance-critical storefronts.

The decisive lever is not limiting schema validation to forms but applying it consistently to API responses too, since response.json() returns unchecked any just as readily as a raw form field returns a string. Structured, field-scoped error objects from flatten().fieldErrors bind directly to UI components, and cross-field rules via .refine() or .superRefine() cleanly cover password confirmations and conditionally required fields too, without losing the overview of the schema.

Type-safely validating form data, the essentials at a glance

Understand type erasure

TypeScript types vanish at runtime, form data is always unknown/string.

Schema as single source

z.infer/v.InferOutput derive the type directly from the validation schema.

Zod vs. Valibot

Zod for ecosystem and DX, Valibot for minimal, tree-shakeable bundles.

FormData & API layer

Use the same schema for native forms, FormData, and typed fetch responses.

11. FAQ: Type-Safely Validating Form Data

1Why isn't a TypeScript interface enough for form data?
Types vanish entirely at runtime due to type erasure. Form data always arrives as unknown or string; an interface checks nothing at runtime.
2What does schema-first validation with z.infer mean?
The schema is the single source of truth, z.infer derives the TypeScript type from it automatically, so type and check can never drift apart.
3When should I use Valibot instead of Zod?
For performance-critical storefronts thanks to its tree-shakeable architecture. Zod often stays more pragmatic in admin tools with a more mature ecosystem.
4How do I connect Zod to native HTML forms and FormData?
FormData is converted into an object, checkboxes and numbers explicitly coerced, then schema.safeParse() validates the result.
5Why should I also validate API responses with a schema?
response.json() returns unchecked any. A response schema with .parse() instantly surfaces contract breaks between backend and frontend.
6How do I get typed field errors for the UI?
error.flatten().fieldErrors gives a field-name-to-error-array mapping, directly bindable to form fields. error.format() preserves nested structures.
7How does cross-field validation like password confirmation work?
.refine() on the whole object gives access to all fields at once. For several rules, .superRefine() with multiple addIssue() calls is a better fit.
8How do I handle conditionally required fields in a schema?
Via .refine() at the object level with a matching path, so the error attaches to the right field, such as a conditionally required billing address.
9Does schema validation noticeably increase bundle size?
Zod adds weight as a connected block, Valibot usually stays at a few kilobytes thanks to tree-shaking, depending on the validators used.
10Does schema validation replace backend validation?
No, frontend validation only improves user experience. Server-side validation remains mandatory, since requests can also arrive from outside your own frontend.