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

@voznov/zod-dto

v0.3.3

Published

Framework-agnostic ZodDto factory — turn Zod schemas into validatable DTO classes

Readme

@voznov/zod-dto

Framework-agnostic DTO factory built on Zod 4. Turn a Zod object schema into a DTO class — validatable, composable, and serializable via JSON.stringify.

Install

pnpm add @voznov/zod-dto zod

Using NestJS? See @voznov/zod-dto-nestjs — validation pipe + automatic OpenAPI/Swagger generation (.default, .describe, recursive schemas, etc. are forwarded to the spec without manual @ApiProperty).

Quick start

import { z } from 'zod';
import { ZodDto, toDto } from '@voznov/zod-dto';

class UserDto extends ZodDto(
  z.object({
    id: z.uuid(),
    name: z.string(),
    email: z.email(),
  }),
) {}

// `UserDto` is both a type and a value under one name — use it directly:
function greet(u: UserDto) { return `Hi, ${u.name}`; }
const user = toDto(UserDto, rawData); // parse + validate; throws ZodDtoValidationError
greet(user);

Prefer class X extends ZodDto(...) {} over const X = ZodDto(...) + type X = z.infer<typeof X> — it collapses the two names into one and instanceof X works for free.

toDto(UserDto, raw) is just UserDto.safeParse(raw) + throw on failure + return the (already-constructed) instance. The DTO class is itself a Zod schema, so you can call .safeParse / .parse (and async variants) directly when you'd rather get a Result than a throw — the returned data is a real UserDto instance either way:

const r = UserDto.safeParse(rawData);
if (r.success) r.data instanceof UserDto; // true

options.in — input preprocessor

Runs as z.preprocess before validation. Schema-embedded, so nested DTOs apply their own in during a parent's safeParse.

class UserDto extends ZodDto(z.object({ userId: z.number(), firstName: z.string() }), {
  in: (data) => /* transform unknown -> parseable shape */ data,
}) {}

A common recipe — snake_case → camelCase aliases (copy into your project):

const aliases =
  (map: Record<string, string>) =>
  (data: unknown): unknown => {
    if (typeof data !== 'object' || data === null) return data;
    const out = { ...data } as Record<string, unknown>;
    for (const [from, to] of Object.entries(map)) {
      if (from in out) {
        if (!(to in out)) out[to] = out[from];
        delete out[from];
      }
    }
    return out;
  };

class UserDto extends ZodDto(z.object({ userId: z.number(), firstName: z.string() }), {
  in: aliases({ user_id: 'userId', first_name: 'firstName' }),
}) {}

toDto(UserDto, { user_id: 1, first_name: 'Ada' });
// -> { userId: 1, firstName: 'Ada' }

toDto.with — preprocessor at the call site, not on the DTO

When the same preprocessor needs to run for every DTO at a layer boundary (e.g. every postgres row goes through snake_case → camelCase), embedding it per-DTO via options.in repeats the same wiring on every class. toDto.with(...) produces a toDto-shaped function with the preprocessor pre-bound:

import { toDto } from '@voznov/zod-dto';

const fromDb = toDto.with(snakeToCamel);

class UserRepository {
  async findOne(id: string): Promise<UserDto> {
    const row: unknown = await postgres.query('select * from users where id=$1', [id]);
    return fromDb(UserDto, row); // unknown narrows to UserDto, snake→camel applied transparently
  }
}

Accepts either a bare function (toDto.with(fn)) or an options object. Chained calls compose: toDto.with(A).with(B) runs A first, then B. The base toDto is unchanged — you can have multiple boundary-specific factories side by side. Supported options:

  • preprocessors: ((data: unknown) => unknown)[] — transform input before validation, applied left-to-right.

  • observers: ((data: unknown) => void)[] — side-effect hooks fired after a successful parse (logging, metrics, tagging). The parsed object is passed by reference, so observer mutations will leak into the result; treat them as advisory.

  • errorClass: new (issues: string[]) => ZodDtoValidationError — constructor used when validation fails. Useful for differentiating boundary errors in exception filters / catch chains:

    class DbValidationError extends ZodDtoValidationError {}
    const fromDb = toDto.with({ preprocessors: [snakeToCamel], errorClass: DbValidationError });
    // try { fromDb(UserDto, row) } catch (e) { if (e instanceof DbValidationError) ... }

Inline options on the call site (toDto(schema, data, { errorClass: ... })) override preset values — the latest errorClass wins, while preprocessors and observers always concatenate.

For schemas with async refines or transforms, use toDto.async(...) (or .async(...) on the function returned by .with(...)) — it routes through safeParseAsync, returns Promise<result>, and preserves the same throw contract on failure:

const AsyncUserDto = ZodDto(z.object({
  email: z.string().refine(async (s) => await isUnique(s), { message: 'taken' }),
}));

await toDto.async(AsyncUserDto, { email: 'a@b' });

const fromDb = toDto.with({ preprocessors: [snakeToCamel] });
await fromDb.async(AsyncUserDto, dbRow);   // .with(...) result also has .async

options.out — serialization hook

Attached to the instance prototype as toJSON. JSON.stringify picks it up automatically; nested DTOs serialize through their own out.

class UserDto extends ZodDto(z.object({ firstName: z.string(), lastName: z.string(), password: z.string() }), {
  out: (parsed) => ({
    fullName: `${parsed.firstName} ${parsed.lastName}`,
    // password stripped
  }),
}) {}

const user = toDto(UserDto, { firstName: 'Ada', lastName: 'Lovelace', password: 'x' });
user.password; // 'x' — instance retains the original parsed shape
JSON.stringify(user); // '{"fullName":"Ada Lovelace"}'

Subclassing with methods (advanced)

You can add methods on the subclass:

class MyPoint extends ZodDto(z.object({ x: z.number(), y: z.number() })) {
  label() {
    return `(${this.x}, ${this.y})`;
  }
}

const p = toDto(MyPoint, { x: 3, y: 4 });
p.label(); // '(3, 4)' — works at top level

This works at runtime everywhere — every DTO node in the parse result is constructed into the right class. But in nested schema positions (z.array(MyPoint), z.object({ p: MyPoint }), discriminated unions, ...) z.infer<> falls back to the plain shape ({x, y}), so you'd need as InstanceType<typeof MyPoint> to reach subclass methods.

To make subclass methods propagate through nested positions in the type system, use the <Self>() two-step:

class MyPoint extends ZodDto<MyPoint>()(z.object({ x: z.number(), y: z.number() })) {
  label() { return `(${this.x}, ${this.y})`; }
}

class List extends ZodDto(z.object({ points: z.array(MyPoint) })) {}
const result = toDto(List, { points: [{ x: 1, y: 2 }] });
result.points[0].label(); // OK — no cast

The generic fills Self so z.infer<> carries the subclass type; the empty () then receives the schema with T properly inferred (TypeScript can't do both partial-explicit generics and inference in one call).

Composition

.extend / .pick / .omit build a new DTO from the base's shape — and the shape is all that carries over. The in hook, the out hook, subclass methods, custom prototype members, and the instanceof relationship to the base are all intentionally dropped — the derived class is not a subclass of the base, so instance instanceof BaseDto is false.

The reason is type safety: a different shape invalidates the typed argument of in/out and may invalidate the bodies of subclass methods (a method that touches this.password would tsc-pass on a derived class that no longer has password). Silently inheriting them would either lie at the type level or crash at runtime.

class BaseDto extends ZodDto(z.object({ id: z.uuid(), name: z.string() })) {}
class CreateDto extends BaseDto.omit({ id: true }) {}
class NamedOnlyDto extends BaseDto.pick({ name: true }) {}
class WithEmailDto extends BaseDto.extend({ email: z.email() }) {}

⚠️ Re-apply out for security-sensitive DTOs

If your base DTO uses out to strip sensitive fields (password, internal IDs, ...), the derived DTO will not inherit it — the field can re-leak through JSON.stringify. Re-apply out (or wrap pick/omit so the field cannot exist in the derived shape at all):

class UserDto extends ZodDto(
  z.object({ id: z.string(), name: z.string(), password: z.string() }),
  { out: ({ password, ...rest }) => rest },
) {}

// ❌ password leaks back — `out` was dropped:
class PublicDto extends UserDto.omit({ id: true }) {}

// ✅ either re-apply `out`...
class PublicDto2 extends ZodDto(
  z.object({ name: z.string(), password: z.string() }),
  { out: ({ password, ...rest }) => rest },
) {}

// ✅ ...or omit the sensitive field from the shape itself:
class PublicDto3 extends UserDto.omit({ id: true, password: true }) {}

Re-attach methods on the derived class

If you need methods on the derived DTO, subclass the result of the derivation:

class Point extends ZodDto(z.object({ x: z.number(), y: z.number() })) {
  sum() { return this.x + this.y; }
}

// ❌ `Point3D.prototype.sum` is undefined — derivations build a fresh class.
const Point3D = Point.extend({ z: z.number() });

// ✅ Subclass the derivation to add methods on the new shape:
class Point3DWithSum extends Point.extend({ z: z.number() }) {
  sum() { return this.x + this.y + this.z; }
}

.partial() / .required() / .merge() — wrap in ZodDto(...)

.extend/.pick/.omit are first-class on a DTO class because they're the most common derivations. Other Zod object methods — .partial(), .required(), .merge(), etc. — are still callable (every Zod schema method is preserved), but they return a plain ZodObject, not a DTO. To get a DTO back, wrap once in ZodDto(...):

class CreateUserDto extends ZodDto(
  z.object({ name: z.string().min(2), email: z.email(), age: z.number().int().min(18) }),
) {}

// CreateUserDto + UpdateUserDto pattern:
class UpdateUserDto extends ZodDto(CreateUserDto.partial()) {}

// Same for `.required()`, `.merge()`, etc.:
class StrictDto extends ZodDto(CreateUserDto.partial().required()) {}

The wrap is intentional, not boilerplate: it picks up the new shape, applies the per-class instance walker, and re-fires onCreate (so Swagger metadata is regenerated on the partial shape — without the wrap you'd get @ApiProperty for the original fields).

ZodDto(dto) is idempotent — ZodDto(dto, options) silently drops options

Passing an existing DTO to ZodDto(...) returns the same class — it's a no-op, intentionally, so chained derivations (class X extends ZodDto(Base.omit({...}))) don't blow up tsc on circular type unification.

⚠️ ZodDto(dto, options) silently drops the options. A DTO is structurally compatible with z.ZodObject so the call type-checks, but at runtime the short-circuit returns the same DTO untouched. The result: a sanitizer like ZodDto(UserDto, { out: ({ password, ...rest }) => rest }) looks correct, the compiler approves, and password ships in the response anyway. Always wrap the underlying schema, not the DTO:

class UserDto extends ZodDto(z.object({ id: z.string(), name: z.string(), password: z.string() })) {}

// ❌ Compiles, but `out` is silently ignored — `password` will leak through `JSON.stringify`.
// const Safe = ZodDto(UserDto, { out: ({ password, ...rest }) => rest });

// ✅ Re-wrap the raw schema:
class SafeUserDto extends ZodDto(z.object(UserDto.shape), {
  out: ({ password, ...rest }) => rest,
}) {}

Nested DTOs

A DTO class is a valid Zod schema, usable wherever a schema is accepted.

class AddressDto extends ZodDto(z.object({ city: z.string() })) {}
class PersonDto extends ZodDto(z.object({ name: z.string(), address: AddressDto })) {}

Unions of DTOs work as schema fields:

class CatDto extends ZodDto(z.object({ kind: z.literal('cat'), name: z.string() })) {}
class DogDto extends ZodDto(z.object({ kind: z.literal('dog'), name: z.string() })) {}
class OwnerDto extends ZodDto(z.object({ pet: z.discriminatedUnion('kind', [CatDto, DogDto]) })) {}

Error handling

import { ZodDtoValidationError } from '@voznov/zod-dto';

try {
  toDto(UserDto, bad);
} catch (e) {
  if (e instanceof ZodDtoValidationError) {
    e.issues; // ['email: Invalid email', 'age: Too small', ...] — full structured list
    e.message; // '2 issues: "email: Invalid email" (+1 more)' — short summary, log-friendly
  }
}

Recipes

BigInt (string ↔ bigint)

// Parse: string/number -> bigint
class AmountDto extends ZodDto(z.object({ amount: z.coerce.bigint().min(0n) })) {}

// Serialize: patch BigInt.prototype once at app bootstrap.
declare global {
  interface BigInt {
    toJSON(): string;
  }
}

BigInt.prototype.toJSON = function (this: BigInt) {
  return this.toString();
};

Mixins (e.g. pagination)

Write a function that takes a schema and returns a DTO:

const withPagination = <T extends z.ZodRawShape>(schema: z.ZodObject<T>) =>
  ZodDto(
    schema.extend({
      page: z.number().int().min(0).optional(),
      limit: z.number().int().min(1).max(100).optional(),
    }),
  );

const ListUsersDto = withPagination(z.object({ search: z.string().optional() }));

Context-aware out

out receives the whole parsed object, so cross-field logic works naturally. It also runs in normal application scope, so request-scoped context (AsyncLocalStorage, etc.) is available:

class ProfileDto extends ZodDto(z.object({ userId: z.uuid(), secret: z.string() }), {
  out: (parsed) => ({
    ...parsed,
    secret: ctx().userId === parsed.userId ? parsed.secret : undefined,
  }),
}) {}

API

| Export | Description | | -------------------------- | ---------------------------------------------------------------------------------- | | ZodDto(schema, options?) | DTO class factory. | | toDto(schema, data, options?) | Validate + return result. Schema is any DTO class or z.ZodType (e.g. z.array(Dto), z.union([...])). Throws ZodDtoValidationError. | | toDto.async(schema, data, options?) | Async variant — uses safeParseAsync so schemas with async .refine / async transforms parse cleanly. Returns Promise<result>; same throw contract on failure. | | toDto.with(fn \| options) | Returns a toDto with a preset preprocessor. Chained .with() composes (left-to-right). The returned function exposes the same .async() variant. | | ZodDtoValidationError | { issues: string[] } thrown by toDto. | | formatZodIssues(issues) | Format z.core.$ZodIssue[] into path: message strings. | | isZodDtoClass(value) | Type guard. | | lazyDto<T>(thunk) | Self-referential z.lazy wrapper. lazyDto<CategoryDto>(() => CategoryDto) sidesteps TS's circular-base-class error so a DTO can reference itself in its own shape (children: z.array(lazyDto<...>(...))). | | registerOnCreate(hook) | Register a callback fired for every DTO class created. Also fires retroactively for DTOs that existed before registration, so extension packages (Swagger etc.) work regardless of import order. |

License

Apache-2.0