Class hydration: ORM recipes
Recipes for hydrating into ORM entity classes (TypeORM, Prisma, Sequelize, etc.). Prerequisite: Class hydration for the general pattern and lift strategies.
Recipes for real ORMs and patterns
TypeORM @Entity()
/**
* Class hydration ORM recipes — TypeORM-style @Entity() pattern
*
* TypeORM entity classes have parameterless constructors by design, so
* `Reflect.construct(Entity, [])` is the right strategy. The hydrated
* value is a fully-decorated entity that the repository would persist
* unchanged. Decorators are omitted here so the example runs without
* `reflect-metadata`; the shape is identical to a @Entity-decorated
* class.
*
* Registered on a `Compose.equivalent` sibling of `OrderSchema` so
* the canonical scenario keeps its plain wire-shape behaviour.
*/
import { Compose } from '../../../src/index.js';
import type { UnbrandType } from '../../../src/types/index.js';
import {
aboxFixtures, createBookstoreDocRegistry,
OrderSchema
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
// The canonical (brand-free) Order shape — what the transform's decode produces
// and encode consumes.
type OrderWire = UnbrandType<typeof aboxFixtures.order>;
// Stand-in for `@Entity()` + `@Column(...)` decorated TypeORM class.
// The class is the wire side (TWire) — it gets decoded to canonical JSON and
// encoded back from it.
class OrderEntity {
declare public customerId: string;
declare public orderId: string;
declare public orderLines: OrderWire['orderLines'];
declare public orderTotal: OrderWire['orderTotal'];
declare public placedAt: OrderWire['placedAt'];
declare public shippingAddress: OrderWire['shippingAddress'];
public status: 'pending' | 'shipped' = 'pending';
public markShipped(): void {
this.status = 'shipped';
}
}
const TypeOrmOrderSchema = Compose.equivalent(
OrderSchema,
{ '$id': 'https://bookstore.example/TypeOrmOrder' } as const
);
jt.set(TypeOrmOrderSchema);
// Class hydration uses a normalize transform in REVERSE: the class is the wire
// side (TWire), the schema's canonical JSON is what `instantiate` validates.
// - decode (wire → canonical): lowers an OrderEntity instance to canonical JSON.
// - encode (canonical → wire): hydrates canonical JSON into an OrderEntity.
// So hydration is `encode`; validating an entity into canonical JSON is `instantiate`.
// `addTransform` (instance-bound) resolves the canonical via the registry's refs.
const TypeOrmOrderTransform = jt.addTransform(TypeOrmOrderSchema, {
'decode': (instance: OrderEntity) => {
return {
'customerId': instance.customerId,
'orderId': instance.orderId,
'orderLines': instance.orderLines,
'orderTotal': instance.orderTotal,
'placedAt': instance.placedAt,
'shippingAddress': instance.shippingAddress
};
},
'encode': (wire) => {
// The canonical Order is structurally wider than the fixture-shaped entity.
// Narrow once at the hydration boundary — the runtime values are the validated
// canonical JSON.
const source = wire as OrderWire;
const entity = Object.assign(Reflect.construct(OrderEntity, []), {
'customerId': source.customerId,
'orderId': source.orderId,
'orderLines': source.orderLines,
'orderTotal': source.orderTotal,
'placedAt': source.placedAt,
'shippingAddress': source.shippingAddress
});
return entity;
}
});
// Hydrate canonical JSON into a class instance — the encode direction.
const entity = jt.encode(TypeOrmOrderTransform, aboxFixtures.order as unknown as OrderWire);
// Whatever flows out of `encode` is ready to call instance methods.
entity.markShipped();
console.assert(entity.status === 'shipped');
console.assert(entity instanceof OrderEntity);
// true
console.log('instanceof OrderEntity:', entity instanceof OrderEntity);
// 'shipped'
console.log('status after markShipped():', entity.status);
// hydrated from fixture
console.log('orderId:', entity.orderId);
TypeORM entity classes have parameterless constructors by design, so Reflect.construct is the right strategy. The hydrated value is a fully-decorated entity that the repository will persist.
Prisma generated model classes
prisma generate emits TypeScript classes with the same field shape as the database row. Treat them exactly like TypeORM entities:
/**
* Class hydration ORM recipes — Prisma generated model classes
*
* `prisma generate` emits TypeScript classes with the same field shape
* as the database row. Treat them exactly like TypeORM entities. If
* the generated class is a type rather than a runtime value (some
* Prisma configurations), define a thin class with the same shape and
* methods and use it as the decode target.
*/
import { Compose } from '../../../src/index.js';
import type { UnbrandType } from '../../../src/types/index.js';
import {
aboxFixtures, createBookstoreDocRegistry,
OrderSchema
} from '../bookstore/index.js';
// The canonical (brand-free) Order shape.
type OrderWire = UnbrandType<typeof aboxFixtures.order>;
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
// Stand-in for `import { Order } from '@prisma/client';`.
// The class is the wire side (TWire).
class PrismaOrder {
declare public customerId: string;
declare public orderId: string;
declare public orderLines: OrderWire['orderLines'];
declare public orderTotal: OrderWire['orderTotal'];
declare public placedAt: OrderWire['placedAt'];
declare public shippingAddress: OrderWire['shippingAddress'];
}
const PrismaOrderSchema = Compose.equivalent(
OrderSchema,
{ '$id': 'https://bookstore.example/PrismaOrder' } as const
);
jt.set(PrismaOrderSchema);
// Class hydration: the class is the wire side, canonical JSON is the runtime.
// Hydration is `encode`; lowering is `instantiate`.
const prismaOrderTransform = jt.addTransform(PrismaOrderSchema, {
'decode': (instance: PrismaOrder) => {
return {
'customerId': instance.customerId,
'orderId': instance.orderId,
'orderLines': instance.orderLines,
'orderTotal': instance.orderTotal,
'placedAt': instance.placedAt,
'shippingAddress': instance.shippingAddress
};
},
'encode': (wire) => {
const source = wire as OrderWire;
return Object.assign(Reflect.construct(PrismaOrder, []), {
'customerId': source.customerId,
'orderId': source.orderId,
'orderLines': source.orderLines,
'orderTotal': source.orderTotal,
'placedAt': source.placedAt,
'shippingAddress': source.shippingAddress
});
}
});
// Hydrate canonical JSON into a Prisma order instance.
const order = jt.encode(prismaOrderTransform, aboxFixtures.order as unknown as OrderWire);
console.assert(order instanceof PrismaOrder);
console.assert(order.orderId === aboxFixtures.order.orderId);
// true
console.log('instanceof PrismaOrder:', order instanceof PrismaOrder);
// same UUID as fixture
console.log('orderId:', order.orderId);
If the generated class is a type rather than a runtime value (some Prisma configurations), define your own thin class with the same shape and methods, and use it as the decode target.
Mikro-ORM and Drizzle
Same pattern. Mikro-ORM @Entity and Drizzle's InferModel-derived classes both satisfy "parameterless constructor, mutable fields"; Reflect.construct handles both.
DDD value object
/**
* Class hydration ORM recipes — DDD value object (Money)
*
* `Money.fromPlain` is the right strategy here because `Money`'s
* constructor enforces invariants. Bypassing it via prototype swap
* would silently allow negative amounts. Registered on a sibling of
* the canonical `MoneySchema` so the bookstore's plain wire shape
* for money stays intact.
*
* The Money class is the wire side (TWire). decode lowers it to canonical JSON,
* encode hydrates canonical JSON into a Money instance.
*/
import { Compose } from '../../../src/index.js';
import {
aboxFixtures, createBookstoreDocRegistry,
MoneySchema
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
class Money {
public static fromPlain(plain: {
'amount': number;
'currency': string;
}): Money {
return new Money(plain.amount, plain.currency);
}
public constructor(
public readonly amount: number,
public readonly currency: string
) {
if (amount < 0) {
throw new RangeError('Money cannot be negative');
}
}
public add(other: Money): Money {
if (other.currency !== this.currency) {
throw new Error('currency mismatch');
}
return new Money(this.amount + other.amount, this.currency);
}
}
const DddMoneySchema = Compose.equivalent(
MoneySchema,
{ '$id': 'https://bookstore.example/DddMoney' } as const
);
jt.set(DddMoneySchema);
// Class hydration: Money is the wire side. decode lowers it to canonical JSON,
// encode hydrates back to a Money instance (via fromPlain).
const dddMoneyTransform = jt.addTransform(DddMoneySchema, {
'decode': (instance: Money) => {
return {
'amount': instance.amount,
'currency': instance.currency
};
},
'encode': (wire) => {
// Narrow at the hydration boundary — runtime values are the validated
// canonical JSON.
const source = wire;
return Money.fromPlain({
'amount': source.amount,
'currency': source.currency
});
}
});
// Hydrate canonical JSON into a Money instance.
const wire = aboxFixtures.rareBook.price;
const hydrated = jt.encode(dddMoneyTransform, wire);
console.assert(hydrated instanceof Money);
console.assert(hydrated.amount === wire.amount);
const doubled = hydrated.add(hydrated);
console.assert(doubled.amount === wire.amount * 2);
// true — fromPlain ran
console.log('instanceof Money:', hydrated instanceof Money);
// rare book price amount
console.log('amount:', hydrated.amount);
// amount * 2 via add()
console.log('doubled amount:', doubled.amount);
fromPlain is the right strategy here because Money's constructor enforces invariants. Bypassing it via prototype swap would silently allow negative amounts.
Active Record
/**
* Class hydration ORM recipes — Active Record pattern
*
* Whatever flows out of `encode` is ready to call `.save()`,
* `.delete()`, or any other instance method. There is no separate
* "hydrate" step in the call site. Registered on a `Compose.equivalent`
* sibling of the bookstore `CustomerSchema`.
*
* The CustomerRecord class is the wire side (TWire). decode lowers it to
* canonical JSON, encode hydrates back to a CustomerRecord instance.
*/
import { Compose } from '../../../src/index.js';
import type { UnbrandType } from '../../../src/types/index.js';
import {
aboxFixtures, createBookstoreDocRegistry,
CustomerSchema
} from '../bookstore/index.js';
// createBookstoreDocRegistry seeds a permissive copy of the bookstore — docs examples extend
// it with ad-hoc demo schemas; strict-graph checking is intentionally off here.
const jt = createBookstoreDocRegistry();
// The canonical (brand-free) Customer shape.
type CustomerWire = UnbrandType<typeof aboxFixtures.customer>;
class CustomerRecord {
declare public addresses: CustomerWire['addresses'];
declare public customerId: string;
declare public email: string;
declare public name: string;
public async delete(): Promise<{ readonly 'deletedId': string }> {
// Stand-in for `DELETE WHERE customerId = this.customerId`.
return { 'deletedId': this.customerId };
}
public async save(): Promise<{ readonly 'savedId': string }> {
// Stand-in for `INSERT OR UPDATE customerId = this.customerId`.
return { 'savedId': this.customerId };
}
}
const ActiveRecordCustomerSchema = Compose.equivalent(
CustomerSchema,
{ '$id': 'https://bookstore.example/ActiveRecordCustomer' } as const
);
jt.set(ActiveRecordCustomerSchema);
// Class hydration: CustomerRecord is the wire side. decode lowers it to canonical JSON,
// encode hydrates back to a CustomerRecord instance.
const activeRecordCustomerTransform = jt.addTransform(
ActiveRecordCustomerSchema,
{
'decode': (instance: CustomerRecord) => {
return {
'addresses': instance.addresses,
'customerId': instance.customerId,
'email': instance.email,
'name': instance.name
};
},
'encode': (wire) => {
// Narrow at the hydration boundary — runtime values are the validated
// canonical JSON.
const source = wire as CustomerWire;
return Object.assign(Reflect.construct(CustomerRecord, []), {
'addresses': source.addresses,
'customerId': source.customerId,
'email': source.email,
'name': source.name
});
}
}
);
// Hydrate canonical JSON into a CustomerRecord instance.
const customer = jt.encode(activeRecordCustomerTransform, aboxFixtures.customer as unknown as CustomerWire);
// Active-record method available immediately on the hydrated value.
const saved = await customer.save();
console.assert(saved.savedId === aboxFixtures.customer.customerId);
// true
console.log('instanceof CustomerRecord:', customer instanceof CustomerRecord);
// same customerId as fixture — no separate hydrate step
console.log('saved.savedId:', saved.savedId);
Whatever flows out of instantiate is ready to call .save(), .delete(), or any other instance method. There is no separate "hydrate" step in the call site.
See also
- Class hydration - general pattern, lift strategies, encode direction, and caveats
Transform.createandjt.encode- the underlying API- Bookstore domain - where
OrderSchemaandCustomerSchemaare defined