Inferring TypeScript Form Types from a Zod Schema with z.infer

When your form’s runtime rules and its compile-time types drift apart, you get bugs the compiler swears cannot happen; this page shows how z.infer derives a single TypeScript type directly from your Zod schema validation so the shape you validate and the shape you consume are guaranteed identical.

When to Use This z.infer Inference Approach

Reach for z.infer when a Zod schema is already your source of truth for a form and you want the parsed result to be strongly typed without hand-writing a parallel interface. It is the right tool when the schema and the type would otherwise be maintained separately and could silently disagree.

  • Use it when a schema already exists (see Using Zod for Complex Form Schemas) and you need a matching type for state, props, or a submit handler.
  • Use it when coercion or transforms mean the input and output shapes differ — z.input and z.output capture both ends.
  • Prefer a hand-written type only when there is no schema and you never intend to validate at runtime.
  • Reach for Composing Pure Validator Functions instead if you want plain functions with no schema library at all.
From schema to inferred type to typed handler A Zod schema feeds z.infer, which produces a TypeScript type consumed by a typed submit handler. One schema drives both runtime validation and compile-time types z.object({ ... }) the single source of truth z.infer<typeof schema> derived TypeScript type onSubmit(data: T) typed, parsed payload
The inferred type flows from the schema into your handler, so validation and typing can never disagree.

Deriving a Form Type with z.infer and Wiring It to reportValidity()

The baseline pattern on this site ships <form novalidate> and drives validity through a single manual Constraint Validation API call. Below, the schema is defined once, the type is inferred with z.infer, and the parse result feeds both native field errors and a fully typed submit payload.

<form id="signup" novalidate>
  <label>Email <input name="email" type="email" required /></label>
  <label>Age <input name="age" type="number" required /></label>
  <button type="submit">Create account</button>
</form>
import { z } from "zod";

// 1. The schema is the single source of truth for BOTH runtime and types.
const SignupSchema = z.object({
  email: z.string().email("Enter a valid email address"),
  // z.coerce converts the string from the DOM into a number at runtime,
  // which is exactly why input and output types can differ (see below).
  age: z.coerce.number().int().min(18, "You must be 18 or older"),
});

// 2. Infer the type. No hand-written interface — it tracks the schema forever.
type Signup = z.infer<typeof SignupSchema>;
// Signup === { email: string; age: number }

const form = document.querySelector<HTMLFormElement>("#signup")!;

form.addEventListener("submit", (event) => {
  event.preventDefault();

  // Clear any custom validity from a previous attempt.
  for (const el of form.elements) {
    if (el instanceof HTMLInputElement) el.setCustomValidity("");
  }

  const raw = Object.fromEntries(new FormData(form));
  const result = SignupSchema.safeParse(raw);

  if (!result.success) {
    // Map each Zod issue onto the matching field's custom validity message.
    for (const issue of result.error.issues) {
      const name = String(issue.path[0]);
      const field = form.elements.namedItem(name);
      if (field instanceof HTMLInputElement) {
        field.setCustomValidity(issue.message);
      }
    }
    // A SINGLE manual call surfaces the native error bubbles + :invalid styling.
    form.reportValidity();
    return;
  }

  // 3. result.data is typed as Signup — age is a real number here, not a string.
  const data: Signup = result.data;
  console.log(data.email, data.age.toFixed(0));
});

Because Signup is derived, renaming email to emailAddress in the schema instantly turns every stale data.email access into a compile error — the type can never lag behind the validator.

z.infer and Its Sibling Utilities: Type Reference

Zod exposes several type-level helpers around a schema. Pick the one that matches whether you care about the value going into the parser or coming out of it.

Option Type Default Purpose
z.infer<T> Type Alias of z.output The parsed/output type — what safeParse().data gives you. Use for handlers and state.
z.input<T> Type The type before transforms/coercion run. Use for the raw values you feed in.
z.output<T> Type Explicit alias for the post-transform result; identical to z.infer.
z.coerce.number() Schema Coerces at runtime, which makes z.input (unknown) differ from z.output (number).
.partial() Schema Produces a schema whose inferred type has all keys optional — handy for draft state.
.pick({ ... }) / .omit({ ... }) Schema Derive a narrower schema; its z.infer is the correspondingly narrowed type.

Verification Steps

import { describe, it, expect, expectTypeOf } from "vitest";
import { z } from "zod";

const SignupSchema = z.object({
  email: z.string().email(),
  age: z.coerce.number().int().min(18),
});
type Signup = z.infer<typeof SignupSchema>;

describe("z.infer stays aligned with the schema", () => {
  it("produces a number for a coerced field", () => {
    const parsed = SignupSchema.parse({ email: "a@b.com", age: "21" });
    expect(parsed.age).toBe(21);
    expectTypeOf<Signup["age"]>().toEqualTypeOf<number>();
  });
});

Edge Cases & Failure Modes

The inferred type collapses to any or {}. This usually means you wrote z.infer<typeof schema> where schema is a value, not a type, or you forgot typeof. Always write z.infer<typeof SignupSchema> — the typeof is mandatory because z.infer operates on the schema’s type, not its runtime value.

z.input and z.output diverge unexpectedly. Any z.coerce.*, .default(), or .transform() makes the input type differ from the output type. If a handler receives raw form values, type its parameter with z.input; if it receives parsed data, use z.infer/z.output. Mixing them produces confusing string vs number mismatches — this is common once you add cross-field validation with transforms.

Optional keys leak into required positions. A field marked .optional() infers as key?: T, but destructuring it without a default can still surface undefined at runtime. Guard optional keys explicitly, or use .default() so both the schema and the inferred type agree the value is always present. Pair the resulting messages with accessible error messaging so users see the same intent the types encode.

Inferring Nested and Array Shapes Without Restating the Structure

The real payoff of z.infer shows up once a form outgrows a flat record. A schema that nests objects and arrays produces a nested inferred type with zero extra annotation, and — critically — the nesting stays honest: change a leaf, and every consumer of the branch above it is re-checked. Hand-written interfaces almost always rot at exactly these depths because nobody remembers to update the parallel interface when the schema gains a field.

import { z } from "zod";

const AddressSchema = z.object({
  line1: z.string().min(1),
  postcode: z.string().regex(/^[A-Z0-9 ]{3,10}$/i, "Invalid postcode"),
});

const OrderSchema = z.object({
  email: z.string().email(),
  // A nested object schema — its own inferred type is reusable on its own.
  shipping: AddressSchema,
  // An array schema. z.infer walks into the element and produces LineItem[].
  items: z
    .array(
      z.object({
        sku: z.string(),
        qty: z.coerce.number().int().positive(),
      }),
    )
    .min(1, "Add at least one item"),
});

type Order = z.infer<typeof OrderSchema>;
// Order === {
//   email: string;
//   shipping: { line1: string; postcode: string };
//   items: { sku: string; qty: number }[];
// }

// Reuse a branch without re-deriving the whole tree.
type Address = z.infer<typeof AddressSchema>;

function formatShipping(address: Address): string {
  return `${address.line1}, ${address.postcode}`;
}

Note that items[number].qty is a number in the inferred type even though it arrives from the DOM as a string, because z.coerce.number() runs during parsing. This is the same input/output split described above, now buried three levels deep — another reason to let inference carry it rather than transcribe it by hand.

Deriving Sub-Form Types with .pick() and .omit()

Multi-step forms and edit-versus-create flows rarely want the whole schema at once. Rather than declaring a second schema, derive a narrowed one with .pick() or .omit() and infer from that. The narrowed type is guaranteed to be a structural subset of the parent, so a value that satisfies the sub-form can always flow back into the full schema without a cast.

// Step one of a wizard only collects contact details.
const ContactStep = OrderSchema.pick({ email: true, shipping: true });
type ContactStepValues = z.infer<typeof ContactStep>;
// { email: string; shipping: { line1: string; postcode: string } }

// An update form that forbids editing the email uses .omit().
const OrderEdit = OrderSchema.omit({ email: true });
type OrderEditValues = z.infer<typeof OrderEdit>;

// Because both are carved from OrderSchema, merging step results back
// together type-checks against Order with no assertion.
function assembleOrder(
  contact: ContactStepValues,
  rest: Omit<Order, "email" | "shipping">,
): Order {
  return { ...contact, ...rest };
}

Keeping every sub-form schema anchored to one parent means a field rename propagates through the wizard steps, the edit form, and the assembly function in a single edit — the compiler enumerates every site that still references the old key.

Frequently Asked Questions

Why do I need typeof inside z.infer?

A Zod schema like SignupSchema is a runtime value, but z.infer is a type-level operator that needs a type. The typeof operator bridges the two by producing the schema's static type. Omitting it leaves you passing a value where a type is required, which fails to compile.

What is the difference between z.infer and z.input?

z.infer (an alias of z.output) is the shape after parsing, coercion, and transforms run. z.input is the shape before any of that. For a schema with z.coerce.number(), z.input allows a string while z.infer guarantees a number.

Can I derive a partial type for draft form state?

Yes. Call SignupSchema.partial() to get a schema whose keys are all optional, then infer from it: type Draft = z.infer<typeof SignupSchema.partial()>. This keeps in-progress state loosely typed while the full schema still gates submission.

Does z.infer walk into nested objects and arrays?

Yes. Inference is fully recursive: a z.array(z.object({ ... })) infers as an array of the element's object type, and a nested z.object infers as a nested object type. You never annotate the depth yourself, and any coercion on a leaf (for example z.coerce.number()) is reflected at that exact position in the output type.

Should I export the inferred type or the schema from a module?

Export the schema as the primary artifact and re-export the inferred type beside it: export const OrderSchema = ... and export type Order = z.infer<typeof OrderSchema>. Consumers that only need the type pay no runtime cost thanks to type-only imports, while modules that must validate still have the schema. Exporting only the type forces every validating caller to redeclare a schema, which reintroduces the drift z.infer exists to prevent.

← Back to Schema-Based Validation with Zod