Free & open source — no account required

v1.2.5-PRICING-19
Web & Frontend • Engineering Documentation

TypeScript → Zod Schema Converter

This technical guide provides an in-depth analysis of the typescript to zod engine, best practices for implementation, and data security standards.

TypeScript to Zod: Schema-First vs Type-First, ts-to-zod, and Runtime Boundaries

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.

Live Example: The Two Patterns Side by Side

// === 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()),
});

Automated Conversion with ts-to-zod

// 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 Types and z.lazy()

// 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

Discriminated Unions

// 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
}

Transforming and Branding Types

// 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>;

Where to Place Validation Boundaries

// 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);
});

Best Practices for Production

  • Prefer schema-first for all new code: Define the Zod schema and derive the TypeScript type with 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.
  • Add domain constraints, not just structural types: TypeScript's type system only expresses shapestring, number, object. Zod adds constraintsz.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.
  • Use 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 both the schema and the inferred type: Export 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.

FAQ

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.

Developer FAQ

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.