npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 write z.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 (plus reflect-metadata)

Installation

npm install class-to-zod zod reflect-metadata

Required 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 @Field decorator. 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 models

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

Circular & 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 emitDecoratorMetadata on, a field typed as a single forward-referenced class (e.g. manager: Manager before Manager is 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 test

Contributing

Pull requests, issues, and feature requests are welcome.

License

MIT