Free & open source — no account required
Free & open source — no account required
This technical guide provides an in-depth analysis of the typescript to zod engine, best practices for implementation, and data security standards.
TypeScript types exist only at compile time — they are completely erased in the compiled JavaScript. This means your interface User { email: string } does nothing at runtime when an API returns { email: null } or omits the field entirely. Zod schemas are runtime objects that both validate data and export TypeScript types via z.infer<typeof schema>. There are two approaches: schema-first (define Zod, derive TypeScript — best for new code) and type-first (write TypeScript interfaces, convert to Zod — best for existing codebases). This page covers both patterns, the ts-to-zod tool for automated conversion, and the architectural question of where to place validation boundaries.
// === PATTERN 1: Schema-First (recommended for new code) ===
// Define once in Zod, derive TypeScript type automatically
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
username: z.string().min(3).max(50),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
age: z.number().int().min(18).nullable(),
tags: z.array(z.string()).default([]),
createdAt: z.coerce.date(),
settings: z.object({
theme: z.enum(['light', 'dark']).default('light'),
locale: z.string().default('en'),
}).optional(),
});
// TypeScript type derived from the Zod schema — always in sync
type User = z.infer<typeof UserSchema>;
// → { id: string; username: string; email: string; role: 'admin'|'editor'|'viewer';
// age: number | null; tags: string[]; createdAt: Date; settings?: {...} }
// === PATTERN 2: Type-First (for existing TypeScript codebases) ===
// Existing TypeScript interface — convert to Zod manually or with ts-to-zod
interface User {
id: string;
username: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
age: number | null;
tags: string[];
}
// Manually derived Zod schema
const UserSchema = z.object({
id: z.string(), // add .uuid() if you know the format
username: z.string(), // add .min(3).max(50) based on business rules
email: z.string().email(), // EmailStr implies email format
role: z.enum(['admin', 'editor', 'viewer']),
age: z.number().nullable(),
tags: z.array(z.string()),
});
// Install
npm install -D ts-to-zod
// ts-to-zod.config.js
module.exports = {
input: 'src/types/**/*.ts',
output: 'src/schemas',
// JsDoc comment tags can guide the generator
// /** @minimum 18 */ → .min(18) on numbers
// /** @format email */ → .email() on strings
};
// Input: src/types/user.ts
export interface User {
/** @format uuid */
id: string;
/** @minLength 3 @maxLength 50 */
username: string;
/** @format email */
email: string;
role: 'admin' | 'editor' | 'viewer';
age: number | null;
tags: string[];
createdAt: Date;
}
// Generated: src/schemas/user.ts
import { z } from 'zod';
const userSchema = z.object({
id: z.string().uuid(),
username: z.string().min(3).max(50),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
age: z.number().nullable(),
tags: z.array(z.string()),
createdAt: z.date(),
});
export type User = z.infer<typeof userSchema>;
// Run:
npx ts-to-zod src/types/user.ts src/schemas/user.ts
// Watch mode:
npx ts-to-zod --watch src/types/user.ts src/schemas/user.ts
// Recursive TypeScript type: a Category can have child categories
interface Category {
id: string;
name: string;
children: Category[];
}
// ts-to-zod handles this, but you can write it manually with z.lazy():
const CategorySchema: z.ZodType<Category> = z.object({
id: z.string(),
name: z.string(),
// z.lazy() defers evaluation — needed because CategorySchema is used before it's complete
children: z.lazy(() => CategorySchema.array()),
});
// Mutual recursion:
interface Post { id: string; author: User; comments: Comment[]; }
interface Comment { id: string; author: User; body: string; replies: Comment[]; }
interface User { id: string; posts: Post[]; }
// For mutual recursion, declare types explicitly
const PostSchema: z.ZodType<Post> = z.object({ ... });
const CommentSchema: z.ZodType<Comment> = z.object({ ... });
const UserSchema: z.ZodType<User> = z.object({ ... });
// Fill in the fields after declaration to avoid reference errors
// TypeScript discriminated union
type ApiResponse =
| { status: 'success'; data: User }
| { status: 'error'; error: string; code: number }
| { status: 'loading' };
// Zod discriminated union — efficient (checks discriminator first)
const ApiResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
data: UserSchema,
}),
z.object({
status: z.literal('error'),
error: z.string(),
code: z.number().int(),
}),
z.object({
status: z.literal('loading'),
}),
]);
// Correctly narrows type in TypeScript
const result = ApiResponseSchema.parse(apiResponse);
if (result.status === 'success') {
result.data.email; // TypeScript knows this is User
}
if (result.status === 'error') {
result.code; // TypeScript knows this is number
}
// Transform: parse + normalize in one step
const UserInputSchema = z.object({
username: z.string().trim().toLowerCase(), // normalize on parse
email: z.string().email().toLowerCase(),
role: z.string().transform(s => s.toUpperCase() as 'ADMIN' | 'EDITOR'),
});
// Brand: create opaque ID types
const UserIdSchema = z.string().uuid().brand<'UserId'>();
type UserId = z.infer<typeof UserIdSchema>;
// → string & { readonly _brand: "UserId" }
// Branded types prevent mixing IDs accidentally:
function getUser(id: UserId) { ... }
getUser("random-string"); // TypeScript error: not a UserId
getUser(UserIdSchema.parse(str)); // correct: parse brands the value
// Derived schemas for API layers
const CreateUserInput = UserSchema
.pick({ username: true, email: true, role: true })
.extend({ password: z.string().min(8) });
const UpdateUserInput = UserSchema
.pick({ username: true, email: true, settings: true })
.partial(); // all fields become optional
type CreateUserInput = z.infer<typeof CreateUserInput>;
type UpdateUserInput = z.infer<typeof UpdateUserInput>;
// Rule: validate at the boundary between your code and the outside world.
// Never validate purely internal data that never crosses a boundary.
// ✅ VALIDATE HERE:
// - API route request bodies (Express, Fastify, Next.js API routes)
// - Response data from external APIs (fetch, axios)
// - localStorage / sessionStorage (may be tampered)
// - Query parameters and URL params
// - Form submission data
// - WebSocket messages
// - Messages from a message queue
// ❌ DON'T VALIDATE HERE:
// - Data that was already validated and typed (internal function calls)
// - Data loaded from your own database through a typed ORM
// - Data constructed by your own code
// Example: Express request validation
import { z } from 'zod';
import type { Request, Response } from 'express';
const CreateUserBody = z.object({
username: z.string().min(3).max(50),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
});
app.post('/users', async (req: Request, res: Response) => {
const result = CreateUserBody.safeParse(req.body);
if (!result.success) {
const errors = result.error.flatten().fieldErrors;
return res.status(400).json({ errors });
}
// result.data is fully typed as { username: string; email: string; role: 'admin'|... }
const user = await db.users.create(result.data);
res.status(201).json(user);
});
z.infer. This gives you a single source of truth — the schema IS the type. The type-first pattern (write interface, convert to Zod) is a migration path for existing code, not a permanent architecture choice.string, number, object. Zod adds constraints — z.string().email().min(1).max(255). A generated schema without constraints is incomplete. Always enrich generated schemas with the business rules the TypeScript type can't express.safeParse at boundaries, parse internally: parse throws on validation failure — appropriate for data that should always be valid (internal pipelines). safeParse returns a result object — appropriate at API boundaries where invalid input is expected and should produce a 400 response, not a 500 crash.export const UserSchema = z.object({...}) for validation and export type User = z.infer<typeof UserSchema> for type annotations. Other modules import the type for typing and the schema for parsing — one definition, two uses.Q: Is ts-to-zod the only tool for TypeScript-to-Zod conversion?
A: No — alternatives include zod-prisma-types (from Prisma schema), prisma-zod-generator, and zodgen. For API response types specifically, openapi-typescript-validator generates Zod schemas from OpenAPI specs, which is often more practical than converting hand-written interfaces.
Q: Does ts-to-zod handle optional vs nullable correctly?
A: It handles T | undefined as .optional() and T | null as .nullable(). The distinction matters: in TypeScript, an optional property (key?: T) is T | undefined, which is different from T | null. Zod's .optional() allows the key to be absent; .nullable() allows the value to be null. Review the generated schema to ensure this matches your intent.
Q: How do I handle API responses where I don't know the full schema?
A: Use z.unknown() for fields you don't want to validate, z.record(z.unknown()) for arbitrary objects, and .passthrough() on an object schema to allow extra keys. This is useful for partial validation — validate only the fields you care about and pass the rest through unchanged.
Q: Should I use Zod or Valibot for new projects?
A: Valibot is significantly smaller (tree-shakeable, ~1KB vs ~60KB for Zod). If bundle size is critical (browser apps, edge functions), Valibot is the better choice. If you need ecosystem compatibility (many libraries export Zod schemas — tRPC, Next.js, React Hook Form adapters), Zod has broader support. Both are excellent; the choice is ecosystem vs bundle size.
Is the processing local-only?
Absolutely. TypeMorph operates entirely within your browser's sandbox. We use Web Workers for high-performance computation without ever transmitting your JSON, SQL, or API data to a remote server.
Can I use this for enterprise projects?
Yes. The tool is designed for professional software engineers who require GDPR compliance and data privacy. It is trusted by developers at top-tier startups and financial institutions.
Why pasting proprietary company data into third-party web tools is a major liability, and how to stay safe.
A deep dive into combining Zod, React Query, and TypeScript for bulletproof API integration.
Code generation is just the beginning. Discover how a schema-first approach can eliminate 90% of your integration bugs.