Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 055 / 100

Types from Zod schemas: a practical guide for developers

One definition, validation and types.

Expona Admin7 min read

Types from Zod schemas

When building production applications, I've learned that repeating type definitions across your codebase creates friction—schema validation and TypeScript types drift apart, breaking at runtime. Types from Zod schemas solve this by deriving your entire type system from a single source of truth, ensuring validation rules and types stay perfectly synchronized. In this post, I'll show you how to define schemas once and extract both runtime validation and compile-time types from them.

The problem: duplicate definitions

Most teams I've worked with fall into the same trap. You define a type in TypeScript:

typescript
type Lead = {
  name: string;
  email: string;
};

Then you write validation logic somewhere else—maybe in your API handler, maybe in a utility function. The type says name can be any string, but your validation requires it to be at least 2 characters. The type says email is a string, but validation enforces email format. These constraints live in two places now, and they'll diverge.

I've debugged this countless times. A frontend sends data that passes client-side validation, but the server rejects it with a different error. Or worse, someone removes a validation rule from the backend without updating the type, and the type no longer describes what the API actually accepts.

With Zod, you define the schema once. Everything else—validation and types—flows from that single source.

Define the schema once

Let's start with a concrete example. Say you're building a lead capture system for an e-commerce platform:

typescript
import { z } from 'zod';

const Lead = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Must be a valid email address'),
});

This schema declares what a Lead looks like. The constraints are explicit and co-located: name is a string with a minimum length, email is a valid email. Nothing is implicit or scattered across multiple files.

When you receive data from an HTTP request, a form submission, or a message queue, you parse it against this schema:

typescript
const result = Lead.parse({
  name: 'Alice',
  email: 'alice@example.com',
});

If validation succeeds, result contains the parsed data. If it fails, Zod throws a ZodError with detailed information about what went wrong. This is your boundary—the point where untrusted input becomes trusted data inside your application.

z.infer derives the type

Now the clever part. TypeScript can extract the type from your schema:

typescript
type Lead = z.infer<typeof Lead>;

This creates a TypeScript type that matches your validation schema exactly. The inferred type is:

typescript
{
  name: string;
  email: string;
}

The beauty is that the constraints from your schema—the .min(2), the .email()—inform static type checking. When you refactor, when you tighten validation, your types update automatically. There's no drift, no synchronization problem.

I use this pattern in every API I build. The schema is the source of truth. If you need to know what fields a request object has, you look at the schema. If you need to know what validation runs, you look at the same place.

Parse untrusted input at boundaries

Boundaries are where external data enters your system: HTTP endpoints, message consumers, file uploads, third-party webhooks. You parse at every boundary.

Let's say you have a NestJS controller receiving lead data:

typescript
import { Controller, Post, Body } from '@nestjs/common';
import { z } from 'zod';

const CreateLeadDto = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  phone: z.string().optional(),
});

type CreateLeadDto = z.infer<typeof CreateLeadDto>;

@Controller('leads')
export class LeadsController {
  @Post()
  async create(@Body() payload: unknown) {
    const data = CreateLeadDto.parse(payload);
    // data is now typed and validated
    return this.leadsService.save(data);
  }
}

Notice the type of payload is unknown. This is intentional—HTTP bodies are untrusted. You don't know what the client sent. Parse it first, then your function can work with the typed result.

In a Kafka consumer, the pattern is the same:

typescript
async handleLeadCreated(message: { value: Buffer }) {
  const payload = JSON.parse(message.value.toString());
  const lead = Lead.parse(payload);
  // now you can safely use lead
  await this.database.insert(lead);
}

I've seen systems crash because they trusted message data without validation. Parse at boundaries. Always.

Share between client and server

One of the biggest wins with this approach is sharing schemas between frontend and backend. Your React client and your NestJS server can use the same validation rules.

Create a shared package or move schemas to a common folder:

typescript
// packages/schemas/src/lead.ts
import { z } from 'zod';

export const Lead = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Must be a valid email address'),
  phone: z.string().regex(/^\+?[0-9]{10,}$/, 'Invalid phone number').optional(),
});

export type Lead = z.infer<typeof Lead>;

Now your React form uses the same schema:

typescript
import { Lead } from '@myapp/schemas';

function LeadForm() {
  const [data, setData] = useState<Partial<Lead>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      const validated = Lead.parse(data);
      const response = await fetch('/api/leads', {
        method: 'POST',
        body: JSON.stringify(validated),
      });
      // handle response
    } catch (error) {
      if (error instanceof z.ZodError) {
        setErrors(
          error.errors.reduce((acc, err) => {
            const path = err.path.join('.');
            acc[path] = err.message;
            return acc;
          }, {} as Record<string, string>)
        );
      }
    }
  };

  return (
    <>
      <input
        value={data.name || ''}
        onChange={(e) => setData({ ...data, name: e.target.value })}
      />
      {errors.name && <span>{errors.name}</span>}
      {/* more fields */}
    </>
  );
}

And your backend:

typescript
import { Lead } from '@myapp/schemas';

@Post()
async create(@Body() payload: unknown) {
  const data = Lead.parse(payload);
  return this.leadsService.save(data);
}

Both client and server enforce the same constraints. The user gets immediate feedback on their form if a field is invalid. The server rejects bad requests with the exact same rules. No inconsistency, no surprises.

Advanced patterns

As your schemas grow, you can compose and extend them:

typescript
const BaseEntity = z.object({
  id: z.string().uuid(),
  createdAt: z.date(),
});

const Lead = BaseEntity.extend({
  name: z.string().min(2),
  email: z.string().email(),
});

const LeadWithNotes = Lead.extend({
  notes: z.string().optional(),
});

type Lead = z.infer<typeof Lead>;
type LeadWithNotes = z.infer<typeof LeadWithNotes>;

You can also use .pick() and .omit() to create variations:

typescript
const UpdateLeadDto = Lead.omit({ id: true, createdAt: true });
type UpdateLeadDto = z.infer<typeof UpdateLeadDto>;

For complex conditional validation, use .refine() or .superRefine():

typescript
const Lead = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  companyName: z.string().optional(),
}).refine(
  (data) => !data.companyName || data.companyName.length > 2,
  { message: 'Company name must be at least 3 characters', path: ['companyName'] }
);

Common mistakes

Forgetting to parse at boundaries. I see developers trust input because they added a Zod schema somewhere, but never actually call .parse(). The schema is just documentation if you don't validate. Always parse untrusted data.

Using z.any() to escape validation. It defeats the purpose. If you don't know what a field should be, use .optional() or .nullable() and think harder about your domain.

Mixing schema definitions. If you have the same concept defined in multiple schemas, you'll run into maintenance pain. Use composition and shared base schemas.

Not handling parse errors gracefully. When Lead.parse(payload) throws, you need to catch it and return meaningful error messages to the caller. A raw ZodError in your API response is not user-friendly.

Key takeaways

  • Define your schema once with Zod. It's the single source of truth for both validation rules and type shape.
  • Use z.infer<typeof SchemaName> to extract TypeScript types. They'll always match your validation logic.
  • Parse untrusted input at system boundaries: HTTP endpoints, message consumers, file uploads, webhooks.
  • Share schemas between client and server using a monorepo or published package. This ensures consistent validation across your entire stack.
  • Handle parse errors intentionally. Catch ZodError and transform it into a response your caller can understand.
  • Compose schemas using .extend(), .pick(), and .omit() to avoid duplication as your domain grows.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles