Model an aggregate
Problem: one entity contains others — an order with a customer and line items, a document with authors — and you want the whole thing to still be an entity rather than a bare schema.
Snippets below assume these imports:
tsimport { z } from "zod"; import { match, P } from "unthrown"; import { Entity } from "@btravstack/entity";
Use the class as a field
An entity class is itself a zod schema, so it is a field like any other:
class Customer extends Entity("Customer")(
{ id: CustomerId, name: Name },
{
computed: {
shout: Entity.computed(
Upper,
(d) => d.name.toUpperCase() as z.infer<typeof Upper>,
),
},
},
) {}
class Order extends Entity("Order")({
id: OrderId,
customer: Customer,
watchers: z.array(Customer),
note: Line,
}) {}Order is a real entity: invariants, deep immutability, make, update, toJSON. The nested entities keep everything that makes them entities:
const order = Order.make(row).getOrThrow();
order.customer instanceof Customer; // true
order.customer.shout; // its computed fields
order.customer._tag; // its tag, for P.tag(...) matching
order.watchers.at(0)?.equals(other); // its behaviourInvariants can span the boundary
class Order extends Entity("Order")(
{ id: OrderId, customer: Customer, note: Line },
{
invariants: [
Entity.invariant(
(d) => d.note.length >= d.customer.name.length,
"note must be at least as long as the name",
),
],
},
) {}Failures name the whole path
A nested field's failure reports where it actually happened, not just which member failed:
Order.make({ ...raw, customer: { id, name: "" } });
// issues: [{ path: ["customer", "name"], message: "Too small: …" }]Serialisation walks the tree
JSON.stringify reaches plain data all the way down, and the result feeds back through make:
const json = JSON.parse(JSON.stringify(order));
// { id, customer: { id, name, shout }, watchers: [...], note }
Order.make(json).getOrThrow().customer instanceof Customer; // trueModel a union of entities
When a field can be one of several entities, declare the discriminant as an ordinary domain field and use Entity.union:
class User extends Entity("User")({
kind: z.literal("user"),
id: UserId,
email: Email,
}) {}
class ServiceAccount extends Entity("ServiceAccount")({
kind: z.literal("service_account"),
id: SvcId,
label: Label,
}) {}
const Member = Entity.union("kind", [User, ServiceAccount]);
Member.make(row).getOrThrow(); // User | ServiceAccount — the real classThe union dispatches on the discriminant rather than trying each branch, so a member whose own validation fails reports its issues rather than every branch's. A payload whose discriminant matches no member fails as an InvalidEntity whose one issue sits at path: ["kind"] and lists the values the union knows.
Two members claiming the same discriminant value is a bug in the declaration, not bad input, so Entity.union throws at declaration time, naming both members — left silent, the last member would win and make would misroute.
The discriminant is a declared field, not _tag, because _tag is non-enumerable and absent after serialisation — a union built on it could not survive a JSON round trip. The two are not redundant: the field discriminates data, the tag matches an instance.
A union is a schema too, so it nests:
class Audit extends Entity("Audit")({ id: AuditId, actor: Member }) {}Match exhaustively on what comes back
const describe = (m: User | ServiceAccount) =>
match(m)
.with(P.tag("User"), (u) => `user:${u.email}`)
.with(P.tag("ServiceAccount"), (s) => `svc:${s.label}`)
.exhaustive();When to reach for extend instead
If the relationship is "the same thing with more fields" rather than "contains a thing", extend rather than nest:
class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) {}That produces a new entity with its own tag and identity — not a variant of Person, and not a subclass, which is refused.