Inferring TypeScript types
Two definitions of the same thing
Without Zod, you end up maintaining two separate definitions of the same data shape: a TypeScript interface for type checking and a validation function for runtime checking.
// Type definition
interface Contact {
name: string;
email: string;
phone?: string;
subject: "general" | "support" | "sales" | "feedback";
message: string;
}
// Validation (separate, might drift from type)
function validate(data: unknown): Contact {
// ... manual checks that might not match the interface
} Two definitions of the same shape. If you add a field to the interface but forget the validator, or vice versa, they drift apart. The type says one thing, the validator checks another. Bugs hide in that gap, and they are hard to find because TypeScript thinks everything is fine.
z.infer: one definition, both purposes
z.infer<typeof Schema> extracts the TypeScript type directly from a Zod schema:
import { z } from "zod/v4";
const ContactSchema = z.object({
name: z.string().trim().min(1).max(100),
email: z.string().trim().toLowerCase().email(),
phone: z.string().trim().min(7).max(20).optional(),
subject: z.enum(["general", "support", "sales", "feedback"]),
message: z.string().trim().min(10).max(5000),
});
type Contact = z.infer<typeof ContactSchema>;
// Equivalent to:
// {
// name: string;
// email: string;
// phone?: string;
// subject: "general" | "support" | "sales" | "feedback";
// message: string;
// } One schema. One type. The schema validates at runtime. The type checks at compile time. They are always in sync because the type is derived from the schema. There is no separate interface to forget about.
Using inferred types in your code
Once you have the type, use it anywhere you would use a regular TypeScript type:
function processContact(contact: Contact): void {
console.log(`New contact from ${contact.name} <${contact.email}>`);
// TypeScript knows: contact.name is string, contact.phone is string | undefined
}
const result = ContactSchema.safeParse(requestBody);
if (result.success) {
processContact(result.data); // result.data is typed as Contact
} result.data is automatically typed as Contact. No casting, no as any, no guessing. TypeScript knows the exact shape because the type comes from the schema.
z.input vs z.output
When schemas have transforms or defaults, the input type is different from the output type. Think about it: if a field has .default("light"), the input can omit it (it is optional), but the output always has it (the default fills it in).
const SettingsSchema = z.object({
theme: z.enum(["light", "dark"]).default("light"),
name: z.string().transform((val) => val.trim().toUpperCase()),
});
type SettingsInput = z.input<typeof SettingsSchema>;
// { theme?: "light" | "dark"; name: string }
// theme is optional (has default), name is a raw string
type SettingsOutput = z.output<typeof SettingsSchema>;
// { theme: "light" | "dark"; name: string }
// theme is always present (default fills it), name is transformed z.input is the type before defaults and transforms, what the caller sends. z.output (which is the same as z.infer) is the type after defaults and transforms, what you get back from parsing.
Use z.input for function parameters that accept raw data. Use z.infer (or z.output) for the processed result. Most of the time, z.infer is all you need.
Exporting types alongside schemas
The convention is to export the schema and the inferred type together:
// src/schemas/contact.ts
export const CreateContactSchema = z.object({
name: z.string().trim().min(1).max(100),
email: z.string().trim().toLowerCase().email(),
message: z.string().trim().min(10).max(5000),
});
export type CreateContactInput = z.infer<typeof CreateContactSchema>;
export const ContactResponseSchema = CreateContactSchema.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
});
export type ContactResponse = z.infer<typeof ContactResponseSchema>; Schemas and types are defined together, exported together, and always in sync. Route handlers use the schema for validation and the type for function signatures. Tests use the same types. Everything agrees on the shape of the data because it all comes from the same source.
With this, we have covered the complete Zod toolkit: primitives, objects, arrays, unions, transforms, refinements, coercion, composition, API integration, error formatting, and type inference. The final lesson pulls it all together into a complete, validated contact form API.
Exercises
Exercise 1: Create a schema. Use z.infer to derive the type. Use the type in a function signature. Verify TypeScript autocomplete works.
Exercise 2: Add a .default() field. Compare z.input and z.output. Note the optional vs required difference.
Exercise 3: Add a .transform(). Compare z.input (pre-transform type) and z.output (post-transform type).
Why is z.infer better than writing a separate TypeScript interface?