A domain-entity builder for TypeScript, on zod v4 — branded fields, immutable data, sealed construction, and Result instead of throws.
One declaration gives you a type, four request/response schemas, behaviour, and
a class that is itself a zod schema — so entities nest inside each other
without losing what makes them entities. Nothing throws: every fallible
operation returns an unthrown
Result.
import { z } from "zod";
import { Entity } from "@btravstack/entity";
const OrgId = z.uuid().brand("OrgId");
const Slug = z.string().min(1).brand("Slug");
const DisplayName = z.string().min(1).brand("DisplayName");
const Instant = z.iso.datetime().brand("Instant");
const Upper = z.string().min(1).brand("Upper");
class Organization extends Entity("Organization")(
{ id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant },
{
generated: ["id", "createdAt"],
immutable: ["id", "createdAt", "slug"],
computed: {
shout: Entity.computed(
Upper,
(d) => d.name.toUpperCase() as z.infer<typeof Upper>,
),
},
invariants: [
Entity.invariant(
(d) => d.name.length <= 80,
"name must be at most 80 characters",
),
],
},
) {
get greeting(): string {
return `Welcome, ${this.name}`;
}
}pnpm add @btravstack/entity zod unthrown @unthrown/standard-schemazod, unthrown and @unthrown/standard-schema are peer dependencies —
install all four.
(Why.)
One pass through the whole lifecycle: declare, create, persist, rehydrate, respond.
// 1. Bind the effect sources once, where your ports already live. The entity
// reads no clock and generates no id itself.
const createOrganization = Organization.factory({
id: () => ids.next(),
createdAt: () => clock.now(),
});
// 2. A create use case supplies only the caller's fields.
const org = createOrganization({ slug, name }).getOrThrow();
org.greeting; // "Welcome, Acme" — from your class body
org.shout; // "ACME" — derived, and re-derived on every construction
// 3. Persist. `toJSON()` projects exactly the stored shape — never `_tag`,
// never your class-body fields.
await db.insert(org.toJSON());
// 4. Rehydrate a row. Same entry point as an untrusted payload: validate,
// re-derive the computed fields, check the invariants, construct.
const loaded = Organization.make(row).getOrThrow();
// 5. Update. Returns a NEW entity; invariants re-run; immutable fields are a
// compile error and are dropped at runtime if smuggled past it.
const renamed = loaded.update({ name: nextName }).getOrThrow();
// 6. Respond. The four schema members are plain `ZodObject`s, so a contract
// layer converts them to JSON Schema in both directions.
const ResponseBody = Organization.output;Failures are values, not exceptions:
import { P } from "unthrown";
Organization.make({ ...row, name: "" }).match({
ok: (o) => o,
errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues), // [{ path: ["name"], … }]
defect: (cause) => report(cause), // a bug in domain code, kept separate
});| Schema member | Type | For |
|---|---|---|
input |
ZodObject |
everything make() accepts |
output |
ZodObject |
stored state and response body |
createInput |
ZodObject |
create request — input minus generated |
updateInput |
ZodObject |
update request — output minus immutable, partial |
| the class | zod schema | parses to an instance; valid as a field, and anywhere zod takes a schema |
| Entry point | Takes | For |
|---|---|---|
SomeEntity.factory(gens)(input) |
caller fields only | a create use case |
SomeEntity.make(data) |
everything input describes |
a row, an event fold, an untrusted import |
entity.update(patch) |
a partial of the mutable fields | an update use case |
entity.toJSON() |
— | the stored data, for a write or a response |
| Option | Meaning |
|---|---|
generated |
fields the domain supplies, never the caller |
immutable |
fields that never change after creation |
computed |
fields derived from the declared ones, re-derived on every construction |
invariants |
rules built with Entity.invariant; any failing rule rejects |
Also Entity.union(discriminant, members) for a union that is itself
entity-like, and SomeEntity.extend(tag)(fields) to build a new entity from an
existing one.
btravstack.github.io/entity — built
with VitePress from docs/, and organised by the four
Diátaxis modes:
- Tutorial — from nothing to a working entity, one step at a time.
- How-to guides — expose an HTTP contract · persist and rehydrate · model an aggregate · test domain logic
- Reference — every member, option and type, with signatures. Plus the generated API reference.
- Explanation — why it is built this way: sealed construction, deep immutability, no I/O, why entities are not subclassable.
See CONTRIBUTING.md for the contribution gate, the commit convention, and how the Node version matrix is chosen.
MIT © Benoit TRAVERS