packages/wrap/src/dto.ts and packages/wrap/src/entity.ts. Zod is the single source of truth for validation, typing, and OpenAPI generation on the DTO side; Drizzle tables are the single source of truth on the entity side.
Entity(name, columns, options?)
function Entity<TName extends string, TColumns extends Record<string, PgColumnBuilderBase>, TRel = {}>(
name: TName,
columns: TColumns,
options?: { relations?: (helpers: EntityRelationBuilders<Tb>) => TRel },
): EntityClass<Tb, TRel>
Builds a Drizzle pgTable(name, { ...BaseRow, ...columns }) and wraps it in a class — the data-mapper vehicle repositories key off, not an ORM active-record. BaseRow (id/createdAt/updatedAt/deletedAt) is merged in automatically; every entity has it.
export class Example extends Entity("examples", {
name: text("name").notNull(),
}, {
relations: ({ many }) => ({ items: many(() => ExampleItem) }),
}) {}
// drizzle-kit + populate typing need these two named exports:
export const ExampleTable = Example.table;
export const ExampleRelations = relationsOf(Example);
type EntityClass<Tb, TRel> = {
new (): InferSelectModel<Tb>; // the typed row shape — never actually instantiated
readonly table: Tb;
readonly tableName: Tb["_"]["name"];
readonly relationsConfig?: (helpers: EntityRelationBuilders<Tb>) => TRel;
};
BaseRow
const BaseRow = {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
deletedAt: timestamp("deleted_at"),
};
updatedAt’s $onUpdate is what makes offline-sync tombstones work: setting deletedAt on a soft-delete also bumps updatedAt, so a client’s next findChangedSince pull sees the row without any extra bookkeeping.
relationsOf(entity, config?)
function relationsOf<Tb, TRel>(entity: EntityClass<Tb, TRel>): Relations<...>;
function relationsOf<Tb, TRel>(entity: EntityClass<Tb, any>, config: (helpers) => TRel): Relations<...>;
Builds the Drizzle relations() value from the entity’s own relationsConfig (declared via Entity(..., { relations })), or from an explicit config argument — use the explicit form when two entities reference each other, since mutual references inside extends Entity(...) type clauses would be a TypeScript type cycle. The callback is evaluated lazily by Drizzle at schema-build time, after every entity module has loaded, so forward references (() => OtherEntity) are safe.
interface EntityRelationBuilders<Tb> {
self: Tb; // this entity's own table, for referencing FK columns
one<TE extends EntityLike>(target: () => TE, config?: { fields: PgColumn[]; references?: (table) => PgColumn[] }): One;
many<TE extends EntityLike>(target: () => TE): Many;
}
references defaults to the target’s id column when omitted. Targets are always referenced through a thunk (() => TargetEntity) so relations can point at entities in other feature slices without import-order issues.
tableOf(source)
function tableOf<S extends PgTable | EntityLike>(source: S): S extends EntityLike ? S["table"] : S;
Resolves a raw PgTable from either an entity class or an already-raw table — used internally by the DTO factories below and by anything else that accepts DTOSource.
DTOs
BaseDTO (abstract)
abstract class BaseDTO {
static schema: z.ZodObject<any>; // override per subclass — the single source of truth
static from<T>(this: new () => T, plain: unknown): T; // parses (throws ZodError on failure), strips unknown keys
static fromStrict<T>(this: new () => T, plain: unknown): T; // @deprecated — from() already strips by default
static safeFrom<T>(this: new () => T, plain: unknown):
| { success: true; data: T }
| { success: false; error: z.ZodError };
validate(): z.core.$ZodIssue[]; // validate the current instance; [] means valid
}
Every DTO in the framework — hand-written or generated by the factories below — is ultimately a BaseDTO subclass with a schema. @ValidateDTO() (Decorators) and @Serialize() both key off schema directly.
SchemaDTO(schema, name?)
function SchemaDTO<S extends z.ZodObject<any>>(schema: S, name?: string): DTOClass<z.infer<S>>
Wrap an arbitrary Zod object schema as a DTO class — the escape hatch for a DTO that isn’t table-derived (a login payload, a webhook body, …):
class LoginDTO extends SchemaDTO(z.object({ email: z.string().email(), password: z.string().min(8) })) {}
Passing name also registers it in DTO_CLASSES under that name (equivalent to @DTO(), useful when the class isn’t declared with an explicit class X extends SchemaDTO(...) statement that a decorator could attach to).
SelectDTO(source, options?)
function SelectDTO<Src extends DTOSource>(
source: Src, // an Entity class or a raw PgTable
options?: { exclude?: string[] },
): DTOClass<Omit<InferSelectModel<Table>, K>>
Derives a DTO from the table’s SELECT shape via drizzle-zod’s createSelectSchema. Registered by table name ("<table>Base", or "<table>Base_excluded_..." when exclude is given) — calling it twice for the same table/exclude combination returns the same cached class.
export const ExampleBase = SelectDTO(Example, { exclude: ["deletedAt"] });
InsertDTO(source, options?)
function InsertDTO<Src extends DTOSource>(
source: Src,
options?: { exclude?: string[] },
): DTOClass<Omit<InferInsertModel<Table>, K | "id" | "createdAt" | "updatedAt" | "deletedAt">>
Same mechanism, derived from the INSERT shape (createInsertSchema). id/createdAt/updatedAt/deletedAt are excluded automatically — a create payload never carries them — on top of whatever options.exclude adds.
export class CreateExampleDTO extends InsertDTO(Example) {}
PartialDTO(DTOClassRef)
function PartialDTO<T>(DTOClassRef: DTOClass<T>): DTOClass<Partial<T>>
All fields optional — the standard shape for a PATCH/update DTO:
export class UpdateExampleDTO extends PartialDTO(CreateExampleDTO) {}
PaginatedResponseDTO(ItemClass)
function PaginatedResponseDTO<T>(ItemClass: DTOClass<T>): DTOClass<PaginatedResponse<T>>
Wraps an item DTO in the standard pagination envelope ({ items, itemCount, page, pageSize, pageCount }) — used to document a findPaginated endpoint’s response shape in Swagger.
Pre-built DTOs
Exported ready to use — every one is already @DTO()-registered:
| DTO | Shape |
|---|---|
PaginationQuerysDTO |
{ page?, pageSize?, search?, sortBy?, sortOrder?, includeDeleted?, populateChildren?, filters? } — matches PaginationQuery. |
BaseErrorDTO |
{ message, success?, details? } |
BaseDeletedSuccessDTO |
{ deleted: boolean, id: string } |
DeleteMultipleDTO |
{ ids: string[] } |
BaseDeleteMultipleSuccessDTO |
{ deleted, deletedCount, requestedCount, message? } |
StatisticsPeriodDTO |
{ count, period, percentage? } |
StatisticsComparisonDTO |
{ current: StatisticsPeriod, previous: StatisticsPeriod, growth, growthPercentage } |
EntityStatisticsDTO |
{ monthly, weekly, yearly } (each a StatisticsComparison) — matches getStatistics() on repositories/services. |
toSerializationSchema(schema)
function toSerializationSchema(schema: z.ZodObject<any>): z.ZodObject<any>
Derives a schema’s output shape for response serialization: identical field-by-field, except every date field accepts Date | string and transforms to an ISO string on parse. Used internally by @Serialize / serialize() so a fresh entity (real Date objects) and an already-serialized payload (ISO strings, e.g. round-tripped through a queue or cache) both parse the same way. Cached per input schema (WeakMap) — cheap to call repeatedly.