Alright, let’s talk about the real magic trick: z.infer. This is the part where Zod stops being just a very diligent bouncer checking IDs at the door and becomes a master forger, creating perfect TypeScript types for you on the spot. It’s the single biggest reason I use Zod everywhere. It eliminates the soul-crushing, mind-numbing, and error-prone busywork of keeping your static types and your runtime validation in sync. You write the schema once, and Zod gives you both the validator and the type. It’s like getting a free dessert that also does your taxes.

The Basic Sorcery: How z.infer Works

At its core, z.infer is a TypeScript type utility, not a runtime function. It uses TypeScript’s conditional types and inference to peer into your schema’s structure and derive a type from it. It’s pure type-system wizardry. You use it like this:

import { z } from 'zod';

// 1. Define your schema. This is your single source of truth.
const UserSchema = z.object({
  id: z.number(),
  username: z.string().min(3),
  email: z.string().email(),
  isActive: z.boolean().default(true),
});

// 2. Derive the TypeScript type using z.infer
type User = z.infer<typeof UserSchema>;

// 3. Poof! The 'User' type is now equivalent to manually writing:
// type User = {
//   id: number;
//   username: string;
//   email: string;
//   isActive: boolean;
// }

// Now you can use it anywhere a type is needed.
function processUser(incomingUser: User) {
  // You get full autocompletion and type safety here.
  console.log(`Hello, ${incomingUser.username}`);
}

// And you use the schema for validation at runtime.
const validatedUser = UserSchema.parse(someUnknownData);
processUser(validatedUser); // This is safe because 'validatedUser' is now of type 'User'

The key thing to notice here is that you’re passing typeof UserSchema (the type of the schema object itself) to z.infer, not the schema directly. This is how TypeScript can introspect it.

It Handles Everything, Even the Weird Stuff

Zod’s type inference is remarkably comprehensive. It’s not just for simple objects. Let’s look at a more complex example to see how it handles Zod’s rich feature set.

const ComplexSchema = z.object({
  basic: z.string(),
  transformed: z.string().transform((val) => val.length), // becomes a number!
  optional: z.string().optional(),
  arrayOfNumbers: z.array(z.number()),
  discriminatedUnion: z.discriminatedUnion('status', [
    z.object({ status: z.literal('success'), data: z.string() }),
    z.object({ status: z.literal('failed'), error: z.instanceof(Error) }),
  ]),
  tuple: z.tuple([z.string(), z.number()]),
  catchAll: z.record(z.string()) // { [key: string]: string }
});

type Complex = z.infer<typeof ComplexSchema>;

// The inferred type is:
/*
type Complex = {
  basic: string;
  transformed: number; // Notice the transformation from string -> number is captured!
  optional?: string | undefined;
  arrayOfNumbers: number[];
  discriminatedUnion: { status: "success"; data: string; } | { status: "failed"; error: Error; };
  tuple: [string, number];
  catchAll: Record<string, string>;
}
*/

See that? It correctly inferred that the .transform() method output a number. It knows about optional fields, unions, tuples, and records. This is the power of having your types generated from a single, runtime-aware source.

Common Pitfalls and The “Oh Crap” Moment

There’s one classic “gotcha” that gets almost everyone, and it’s a doozy. It happens when you try to use z.infer on a schema that hasn’t been defined as a constant first.

// 🚨 DON'T DO THIS 🚨
type BadUser = z.infer<typeof z.object({
  name: z.string()
})>;

// Error: 'z' refers to a value, but is being used as a type here.

Why does this happen? Because z.object({...}) is a function call that returns a value. You’re trying to use the value z in a type context (typeof), but the schema itself is defined inline inside the type expression. The TypeScript compiler gets confused and can’t resolve it.

The fix is simple and always the same: define your schema as a variable or constant first.

// ✅ DO THIS ✅
const GoodSchema = z.object({
  name: z.string()
});
type GoodUser = z.infer<typeof GoodSchema>;

This works because typeof GoodSchema is a clear, concrete type that TypeScript can easily analyze. This pattern is non-negotiable. Always define the schema, then infer the type.

Best Practice: Export Both

When you’re creating a module for your types, the most powerful pattern is to export both the schema and the inferred type. This gives consumers of your module a choice: they can use the schema for validation, or just use the type if they’re getting data from a already-validated source (like your own database).

// user.ts
export const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

// This is the magic line. Export the inferred type.
export type User = z.infer<typeof UserSchema>;

// ---

// In another file:
import { User, UserSchema } from './user';

// Need to validate unknown data? Use the schema.
const myUser = UserSchema.parse(data);

// Just need the type for a function argument? Import the type.
function logUser(u: User) {
  console.log(u.name);
}

This approach is beautifully DRY (Don’t Repeat Yourself). You are, quite literally, never wrong about the shape of a User ever again. If you need to add a new field, you change exactly one place—the schema—and the type updates automatically across your entire codebase. It feels less like programming and more like telling the computer what the rules of the universe are and having it enforce them for you. And that’s the point.