@voznov/zod-dto
v0.3.3
Published
Framework-agnostic ZodDto factory — turn Zod schemas into validatable DTO classes
Maintainers
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 zodUsing 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; // trueoptions.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 .asyncoptions.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 levelThis 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 castThe 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 withz.ZodObjectso the call type-checks, but at runtime the short-circuit returns the same DTO untouched. The result: a sanitizer likeZodDto(UserDto, { out: ({ password, ...rest }) => rest })looks correct, the compiler approves, andpasswordships 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. |
