class-to-zod
v1.1.1
Published
Convert TypeScript classes into Zod schemas using decorators. Supports nested models, arrays, inheritance, optional/nullable fields, and circular references.
Maintainers
Readme
class-to-zod
Convert TypeScript classes into Zod schemas using decorators — without repeating yourself.
Annotate your class once with @Field() and the Zod type is inferred from your TypeScript type. Add an explicit type only when you need validation rules or for things the type system erases at runtime (arrays, unions).
@Model()
class User {
@Field() name: string; // → z.string() (inferred)
@Field() age: number; // → z.number() (inferred)
@Field() active: boolean; // → z.boolean() (inferred)
@Field() createdAt: Date; // → z.date() (inferred)
@Field(z.string().email()) email: string; // explicit, for validation rules
}
const UserSchema = getZodSchema(User);Features
- ✔ Type inference —
@Field()reads your TS type; no need to writez.string()on every field - ✔
@Model()/@Field()decorators - ✔ Nested models (inferred or explicit)
- ✔ Multi-level arrays (
T[],T[][],T[][][], …) - ✔ Optional, nullable, and combined optional + nullable fields
- ✔ Circular & self-referential models via lazy thunks (
() => Class) - ✔ Class inheritance (fields are inherited and merged)
- ✔ Generates real Zod schemas with full runtime validation
- ✔ Ships both CommonJS and ESM builds + TypeScript declarations; only dependency is
zod(plusreflect-metadata)
Installation
npm install class-to-zod zod reflect-metadataRequired tsconfig
Type inference relies on decorator metadata, so both of these must be enabled in your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}class-to-zod imports reflect-metadata for you, so you do not need to import it manually.
Quick Start
import { Model, Field, getZodSchema } from "class-to-zod";
import { z } from "zod";
@Model()
class User {
@Field() name: string;
@Field() age: number;
@Field(z.string().email()) email: string;
}
const UserSchema = getZodSchema(User);
UserSchema.parse({ name: "John", age: 20, email: "[email protected]" }); // ✓Type inference
@Field() with no argument maps your TypeScript type to a Zod schema:
| TypeScript type | Inferred Zod schema |
| -------------------- | -------------------------- |
| string | z.string() |
| number | z.number() |
| boolean | z.boolean() |
| Date | z.date() |
| bigint | z.bigint() |
| another @Model class | nested schema (via z.lazy) |
If inference can't determine the type, @Field() throws a clear error telling you to pass an explicit type — it never silently falls back to z.any().
When you still pass an explicit type
Inference works from runtime metadata, which cannot see:
- Validation rules —
@Field(z.string().email()),@Field(z.number().int().positive()) - Arrays — the element type is erased, so write
@Field([z.string()])or@Field([Item]) - Unions / literals / enums —
@Field(z.union([z.string(), z.number()]))
⚠️ Every property still needs a
@Fielddecorator. TypeScript only emits type metadata for properties that carry at least one decorator, so a bare (undecorated) property is invisible to the library.
Nested models
@Model()
class Address {
@Field() street: string;
@Field() city: string;
}
@Model()
class User {
@Field() name: string;
@Field() address: Address; // inferred nested schema
}Arrays — including multi-level
Arrays are always explicit (the element type is erased at runtime):
@Field([z.string()]) tags: string[]; // one level
@Field([[z.string()]]) matrix: string[][]; // two levels (matrix)
@Field([Address]) addresses: Address[]; // array of models
@Field([[Address]]) grid: Address[][]; // nested array of modelsWorks to any depth — [T], [[T]], [[[T]]], …
Optional & nullable
@Field(Optional(z.string())) nickname?: string; // may be omitted
@Field(Nullable(z.string())) middle: string | null; // may be null
@Field(Optional(Nullable(z.string()))) note?: string | null; // bothCircular & self-referential models
Reference a class that isn't defined yet with a lazy thunk — () => Class:
// Self-referential tree
@Model()
class TreeNode {
@Field() label: string;
@Field(Optional([() => TreeNode])) children?: TreeNode[];
}// Mutual recursion between two classes
@Model()
class Manager {
@Field() name: string;
@Field([() => Employee]) reports: Employee[];
}
@Model()
class Employee {
@Field() name: string;
@Field([() => Manager]) managers: Manager[];
}Resolution is deferred with z.lazy() internally, so schemas build correctly no matter the declaration order.
⚠️ Same-file caveat: with
emitDecoratorMetadataon, a field typed as a single forward-referenced class (e.g.manager: ManagerbeforeManageris declared) triggers a TypeScript "used before initialization" error, because TS emits an eager type reference. Avoid it by keeping mutually-recursive models in separate files (the usual case), or by referencing through an array thunk as shown above.
Inheritance
@Model()
class BaseEntity {
@Field() id: string;
}
@Model()
class User extends BaseEntity {
@Field() name: string;
}
// getZodSchema(User) includes both `id` and `name`.Real-world example
@Model()
class Review {
@Field() stars: number;
@Field(Optional(z.string())) comment?: string;
}
@Model()
class Product {
@Field() name: string;
@Field(z.number().positive()) price: number;
@Field(Optional([Review])) reviews?: Review[];
@Field([[z.string()]]) tagGroups: string[][];
@Field(Optional(Nullable(() => Product))) related?: Product | null;
}
const ProductSchema = getZodSchema(Product);API
| Export | Description |
| ----------------------------- | ----------------------------------------------------------------------- |
| @Model() | Marks a class as a schema model. |
| @Field(type?) | Marks a property. Omit type to infer it; pass a Zod type / class / array / thunk to be explicit. |
| Optional(type) | Wraps a type as optional. |
| Nullable(type) | Wraps a type as nullable. |
| getZodSchema(Class) | Builds and returns the ZodObject schema for a @Model class. |
Testing
The package ships with an end-to-end test suite (run against the built output):
npm testContributing
Pull requests, issues, and feature requests are welcome.
License
MIT
