Free & open source — no account required

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

Zod to TypeScript Converter

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

Zod to TypeScript: Extract Standalone Types From Your Schemas

If you already have Zod schemas, the usual way to get a TypeScript type is z.infer<typeof Schema>. But that keeps a hard dependency on Zod and on the schema's runtime value. Sometimes you want the plain type instead — a standalone interface or type with no import { z } attached: for a published package's .d.ts, for a type-only consumer, for documentation, or to hand the shape to a codegen step that never runs Zod. This converter reads a Zod schema and emits the equivalent TypeScript. It is a high-quality starting point — every mapping is mechanical and visible, so you can read exactly what each line became and adjust it.

Live Example

// Input: Zod schema
import { z } from 'zod';

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zip: z.string().optional(),
});

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  age: z.number().int(),
  role: z.enum(['admin', 'user', 'guest']),
  address: AddressSchema,
  bio: z.string().nullable(),
  nickname: z.string().nullish(),
  tags: z.array(z.string()),
});

// Output: standalone TypeScript — no Zod import needed
interface Address {
  street: string;
  city: string;
  zip?: string;
}

interface User {
  id: string;
  name: string;
  age: number;
  role: 'admin' | 'user' | 'guest';
  address: Address;
  bio: string | null;
  nickname?: string | null;
  tags: string[];
}

z.infer vs. an Extracted Type — When to Use Which

These solve different problems:

// z.infer — derives the type from the schema VALUE.
// Requires the schema (and Zod) to exist at the point of use.
type User = z.infer<typeof UserSchema>;

// Extracted type — a standalone declaration with no runtime tie.
// Use when the consumer must NOT depend on Zod, or when you only
// ship types (a library's public .d.ts, a shared types package).
interface User { /* ... */ }

Rule of thumb: inside the app that owns the schema, prefer z.infer so the type can never drift from the validator. When you need to publish or share the shape beyond that boundary, extract a standalone type.

Input vs. Output Types — the Transform Gotcha

A Zod schema can describe two different shapes when it transforms, coerces, or defaults a value. z.infer is an alias for z.output — the shape after parsing. The shape you pass in is z.input.

const Schema = z.object({
  // input: string, output: Date
  createdAt: z.string().datetime().transform(s => new Date(s)),
  // input: optional, output: always present (default applied)
  role: z.string().default('user'),
  // input: string | number, output: number
  count: z.coerce.number(),
});

type Input  = z.input<typeof Schema>;
//  { createdAt: string; role?: string; count: string | number }
type Output = z.output<typeof Schema>;
//  { createdAt: Date; role: string; count: number }

When you extract types, decide which side you mean: the input type for request bodies/forms (what a caller sends), the output type for what your code receives after validation. Mixing them up is the most common source of "the types look wrong" confusion.

How Each Construct Maps

  • z.string(), z.number(), z.boolean()string, number, boolean
  • .optional()field?: T (key may be absent)
  • .nullable()T | null (present, may be null)
  • .nullish()field?: T | null (absent or null)
  • z.enum([...]) → a string-literal union
  • z.literal('x')'x'
  • z.array(T)T[]; z.record(K, V)Record<K, V>
  • z.object({...}) → an interface (nested objects become their own named interfaces)
  • z.union([...])A | B; z.discriminatedUnion('type', [...]) → a discriminated union
  • z.tuple([...])[A, B]

What Does NOT Survive the Trip — by Design

TypeScript types describe shape, not runtime constraints. So refinements that live only at runtime have no type-level equivalent and are intentionally dropped:

z.string().min(3).max(20).regex(/^[a-z]+$/)
// → just: string   (min/max/regex are runtime checks, not types)

z.number().int().positive()
// → just: number   ('integer' and 'positive' aren't TS types)

z.string().refine(isValidIban)
// → just: string   (custom refinements can't be expressed in a type)

This is correct, not lossy: those rules belong on the Zod schema, which stays the source of truth for validation. If you need the constraint enforced, keep validating with Zod and use the extracted type only for shape. Branded types (z.string().brand('Email')) are the one way to carry a distinction into the type system.

Discriminated Unions

// Input
const Event = z.discriminatedUnion('type', [
  z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
  z.object({ type: z.literal('key'), key: z.string() }),
]);

// Output
type Event =
  | { type: 'click'; x: number; y: number }
  | { type: 'key'; key: string };

// Narrowing works exactly as with hand-written unions
function handle(e: Event) {
  if (e.type === 'click') console.log(e.x, e.y);
}

Common Pitfalls

  • Assuming the type enforces validation: a User interface won't reject age: -5. Keep the Zod schema for runtime checks; the type is shape only.
  • Picking the wrong side of a transform: if a field is z.coerce.number(), the request body is a string. Use z.input for the inbound type.
  • Recursive schemas: a self-referential schema needs an explicit type plus z.lazy(). Define the interface first, then annotate the schema with it.
  • Flattening nested objects: prefer extracting named sub-interfaces (Address, Contact) over one giant inline type — easier to reuse and read.

FAQ

Q: Why not just use z.infer everywhere?
A: Inside the code that owns the schema, do. Extract standalone types when a consumer must not depend on Zod — a published package's public types, a shared types package, or input to a tool that doesn't run Zod.

Q: My extracted type doesn't match what I send to the API. Why?
A: You're likely looking at the output type while sending the input shape. Transforms, .default(), and z.coerce make z.input and z.output differ. Use the input side for request payloads.

Q: Where did my .min() / .email() rules go?
A: They're runtime constraints with no TypeScript equivalent, so they don't appear in the type. They remain enforced by the Zod schema. Use .brand() if you want to carry a nominal distinction (e.g. an Email branded string) into the type system.

Q: Does this handle recursive or mutually-referential schemas?
A: Mechanical extraction covers the common cases. Recursive shapes need an explicit interface plus z.lazy() on the schema side — treat the output as a scaffold and wire the recursion by hand.

Q: Is anything uploaded?
A: No. Conversion runs entirely in your browser — your schemas never leave the machine.

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.