TypeScript to Zod schema
Paste a TypeScript interface, type alias, or enum, or a whole .ts file of them, and the Zod schema for each appears as you type, declared after the schemas it uses. Anything the converter cannot express is named in a note under the result. Nothing you paste leaves this tab.
How to use it#
- Paste your TypeScript into the box above: interfaces, type aliases, and enums, as many as you like.
- Keep inferred types to get a z.infer type beside each schema, and tick semicolons if your code uses them.
- Read the notes under the schema for anything that was left out or written differently.
- Copy the schema from beside the result, or download it as a .ts file.
Why a schema when you already have the interface#
An interface is checked when the code compiles and then disappears. When JSON arrives from an API, a form, or localStorage, TypeScript takes your word that it matches, and a missing field turns up later as undefined somewhere far from the cause. A Zod schema is checked when the data arrives: UserSchema.parse(data) returns the data, typed, or throws an error naming the field that did not match. safeParse runs the same check and returns the result instead of throwing.
Once the schema exists, the interface can go. With inferred types on, each schema has export type User = z.infer<typeof UserSchema> beside it, so the rest of your code keeps importing User as before. If you keep both the interface and the schema, they can drift: a field added to one and not the other still compiles, and the check quietly stops matching the type. Inferring the type from the schema leaves one source to edit.
How each TypeScript construct maps to Zod#
string, number, boolean, bigint, symbol, null, undefined, unknown, any, and never become the Zod schema of the same name, and Date becomes z.date(). A literal such as 'admin', 1, or true becomes z.literal, and a union made only of string literals becomes z.enum(['admin', 'member']). Other unions become z.union, and | null becomes .nullable().
Arrays, whether written string[], Array<string>, or ReadonlyArray<string>, become z.array. readonly is dropped with no loss, because it limits what your code may assign and says nothing about what the data looks like. Tuples become z.tuple, with .rest() for a rest element. Record<string, T> and an index signature { [key: string]: T } become z.record(z.string(), T), and Record over a union of string keys becomes a z.object with each key spelled out. An object with named fields and an index signature becomes a z.object with .catchall() for the other keys. Set<T> and Map<K, V> become z.set and z.map.
An interface that extends another becomes BaseSchema.extend({ ... }), and A & B on two object types does the same. Any other intersection becomes .and(). Partial, Required, Pick, and Omit become .partial(), .required(), .pick(), and .omit(). Every schema is declared after the schemas it uses, because a const cannot be read before its line runs. JSDoc comments on types and fields are carried over.
Optional fields and undefined are not the same#
name?: string means the key can be missing. name: string | undefined means the key must be there but can hold undefined. The first becomes z.string().optional() and the second z.union([z.string(), z.undefined()]). Zod 4 holds the difference: an object without the second key fails. Zod 3 accepts a missing key for both, so on Zod 3 they check the same thing.
name?: string | null becomes z.string().nullable().optional(), which accepts a missing key, null, or a string. Inside an array, where there is no key to be missing, | undefined becomes .optional().
Enums and recursive types#
An enum is copied into the output as it was written and checked with z.nativeEnum(Role), which works in Zod 3 and Zod 4. Zod 4 marks nativeEnum as deprecated in favour of z.enum(Role), which takes the same enum, so on Zod 4 you can swap the call when you like. A const enum is written as a plain enum, since a schema needs the enum object at run time and a const enum has none. A union of string literals such as 'admin' | 'member' needs no enum at all and becomes z.enum.
A type that refers to itself, such as a tree node with children: TreeNode[], cannot be a plain const, because the schema would be read before it exists. It becomes z.lazy(() => ...), and TypeScript cannot infer a type through z.lazy, so the interface is written out and the schema is annotated with it: export const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(...). Two types that refer to each other are handled the same way.
What it does not convert#
Generic types with type parameters, such as Box<T>, are left out, because a schema has no parameter to fill in. Conditional types, mapped types, keyof, typeof, indexed access such as User["id"], and template literal types compute one type from another, and Zod has nothing to compute them with. Function types and methods are left out too, since a schema checks data and cannot check what a function does. So are classes; write the fields as an interface to get a schema for them.
A type that is not in the paste, such as one imported from another file, becomes z.unknown() with a comment naming it, so paste its declaration too. Each of these is listed in the notes under the result, with the field it came from, and the rest of the file is converted as usual. A tuple with an optional element is written with .optional(), which Zod 4 accepts as missing and Zod 3 still requires.
Frequently asked questions#
Does the output work with Zod 3 and Zod 4?
#
Yes. It uses z.object, z.union, z.enum, z.nativeEnum, z.lazy, z.record with two arguments, .extend, .pick, .omit, .partial, and .required, which exist in both, and the import is the same: import { z } from 'zod'. Where the two versions check something differently, such as a key typed | undefined or an optional tuple element, the notes and the section above say so.
Why is my interface written out in the output?
#
Because it refers to itself, or to a type that refers back to it. Its schema has to use z.lazy, and TypeScript cannot infer a type through z.lazy, so the schema is annotated with the interface, which has to be in the file. The same goes for an enum, which z.nativeEnum reads at run time.
Why did a field become z.unknown()?
#
Because it uses a type the converter cannot turn into Zod, or one that is not in the paste. The comment beside it and the note under the result say which. Paste the missing declaration, or write that one field by hand, for example z.custom<Handler>((value) => typeof value === "function") for a function.
How are enums converted?
#
The enum is kept as it was written and checked with z.nativeEnum, which accepts its values and rejects anything else. A string literal union such as 'draft' | 'live' becomes z.enum(['draft', 'live']) instead, and a single enum member such as Status.Draft becomes z.literal(Status.Draft).
Can it convert Zod back to TypeScript?
#
It does not need to. z.infer<typeof UserSchema> is the TypeScript type, and the output has one beside every schema while inferred types are ticked. To start from a JSON sample rather than from types, JSON to Zod writes the schema from the data itself.
Can I paste a whole .ts file?
#
Yes. The parser reads any TypeScript, so imports, functions, and other code do not stop it. Only the type declarations are converted, and a note says the rest was left out. A type imported from another file shows up as z.unknown() until its declaration is pasted too.
Is my code sent anywhere?
#
No. The TypeScript is parsed and converted in this browser tab as you type, so the types of a private codebase stay on your machine.