A domain-entity builder for TypeScript, on zod v4 — branded fields, immutable data, sealed construction, and Result instead of throws.
An entity is simultaneously four things: a type your domain code programs against, a validator for data crossing a trust boundary, a value with behaviour (methods, invariants), and something that nests inside other entities. Most tools in the TypeScript ecosystem give you two or three of these at once — a validation library gives you a type and a validator; a plain class gives you a type and behaviour — and stitching the rest together by hand is exactly the kind of repetitive, error-prone work a library should absorb.
Effect's Schema.Class
gets all four right at once, and is the closest existing prior art. This
package targets the same shape of solution on top of zod v4 and
Standard Schema, with entry points named for
the use case they serve (create, update, make, decode) instead of one
generic decode.
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");
class Organization extends Entity("Organization")(
{ id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant },
{
generated: ["id", "createdAt"],
immutable: ["id", "createdAt", "slug"],
invariants: (d) => (d.name.length > 0 ? [] : ["name must not be empty"]),
},
) {
get greeting(): string {
return `Welcome, ${this.name}`;
}
}That one declaration gives you:
- a type —
Organization's data fields, read-only, each carrying a branded (nominal) type; - validators —
Organization.encoded/.decoded/.createInput/.updateInput, four plainZodObjects a contract layer can hand straight to a JSON Schema converter, plusOrganization.instancefor decoding straight to a class instance; - behaviour — the class body (
greetingabove) plus built-inupdate/encode/toJSON/equals; - composability —
Organization.instancenests insidez.object({...})orz.array(...)and decodes to a realOrganization, so an aggregate can hold other entities without losing their behaviour.
Every fallible operation returns an
unthrown Result<T, InvalidEntity>
instead of throwing — call .getOrThrow(), .match(), or any other Result
combinator on it.
pnpm add @btravstack/entity zod unthrown @unthrown/standard-schemazod, unthrown and @unthrown/standard-schema are peer dependencies
(see Peer dependencies for why) — install all three
alongside @btravstack/entity.
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");
class Organization extends Entity("Organization")(
{ id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant },
{
generated: ["id", "createdAt"],
immutable: ["id", "createdAt", "slug"],
},
) {}
// A create use case: the caller supplies request fields, the domain supplies
// identity and the timestamp from injected sources (never `Date.now()`/`crypto.randomUUID()`
// inline — see "No I/O" below).
const org = Organization.create(
{
slug: "acme" as z.infer<typeof Slug>,
name: "Acme" as z.infer<typeof DisplayName>,
},
{
id: "0199b1f4-1b1e-7000-8000-000000000000" as z.infer<typeof OrgId>,
createdAt: "2026-08-06T09:00:00Z" as z.infer<typeof Instant>,
},
).getOrThrow();
org.slug; // "acme" — typed, read-only
org.update({ name: "Acme Inc" as z.infer<typeof DisplayName> }); // a NEW entity; Result<Organization, InvalidEntity>
org.encode(); // the stored data — never carries `_tag`
org.equals(otherOrg); // true when both are `Organization` and their encoded data is equal
// A row mapper, or an event fold's final step:
Organization.make(rowFromDatabase);
// A full untrusted payload — an import, a replayed integration event:
Organization.decode(rawJson);new Organization(...) does not compile — construction is sealed; see
Sealed construction.
Organization.encoded; // ZodObject — full wire object; decode() accepts
Organization.decoded; // ZodObject — stored state and response body; make() accepts
Organization.createInput; // ZodObject — encoded minus generated
Organization.updateInput; // ZodObject — decoded minus immutable, partial
Organization.instance; // ZodType<Organization> — decodes to a class instanceContracts compose the four ZodObjects. Domain code composes instance.
This is the rule the whole design turns on, and it comes from a real
constraint in zod's schema-to-JSON-Schema conversion:
A schema that carries a .transform() — which is what turns parsed data into
a class instance — has no output representation. instance does exactly
that (it decodes to Organization, not to plain data), so:
z.toJSONSchema(Organization.decoded, { io: "output" }); // ✓ real JSON Schema
z.toJSONSchema(Organization.instance, { io: "output" }); // ✗ throws — by designThe four plain ZodObjects (encoded, decoded, createInput,
updateInput) generate JSON Schema in both "input" and "output"
directions, so an HTTP contract layer can hand them straight to a schema
converter with no hand-written omit lists:
const CreateBody = Organization.createInput;
const UpdateBody = Organization.updateInput;
const ResponseBody = Organization.decoded;instance is the composable surface for domain code — the only member that
produces real class instances:
const Aggregate = z.object({
organization: Organization.instance,
members: z.array(Member.instance),
});
Aggregate.parse(raw).organization instanceof Organization; // trueinstance is also a Standard Schema — the
class itself carries a non-enumerable ~standard property delegating to
instance, so @unthrown/standard-schema's fromSchema(Organization)
returns a (raw) => Result<Organization, Issues> validator directly:
import { fromSchema } from "@unthrown/standard-schema";
const parseOrg = fromSchema(Organization);
parseOrg(raw).getOrThrow(); // OrganizationIt does not make z.object({ owner: Organization }) work — zod requires
a real ZodType, so nesting always goes through Organization.instance.
| Entry point | Input | Use |
|---|---|---|
Entity.create(input, generated) |
caller fields + domain-generated fields | a create use case |
entity.update(patch) |
a partial of the mutable fields | an update use case |
Entity.make(state) |
full stored state | row mappers, event folds |
Entity.decode(raw) |
full encoded payload | untrusted input already carrying every field |
The types and the schemas are derived from the same declarations, so the rules are compile-time facts:
Organization.create({ slug, name }, { id: ids.next(), createdAt: clock.now() });
Organization.create({ slug, name, id }, generated); // ✗ id is generated
org.update({ name }); // ✓ Result<Organization, InvalidEntity>
org.update({ id }); // ✗ id is immutableWhy create takes the generated values rather than producing them.
Generating a uuid or reading the clock is I/O; this package does none of it.
The use case supplies id/createdAt (or whatever else is generated) from
an injected id source and clock, so the entity stays pure and a test passes
fixed values instead of stubbing crypto.randomUUID/Date.now. What stays in
the domain is the rule — which fields the domain owns, and that a caller may
never send them.
Why decode survives alongside create/update/make. create and
update cover the use-case paths and make covers rehydration from state
that's already valid shape (a database row, a folded event stream). decode
remains for a full encoded payload from an untrusted source that legitimately
carries every field: an import, a replayed integration event, an entity
nested inside another entity's payload (instance runs decode under the
hood, which is why nesting works).
update returns a new entity — data is immutable — and re-runs
invariants, so a patch where every individual field is valid but the
combination is not still fails.
decode(x.encode()) does not round-trip for an entity with a decoded
option (see below) — a consumed field like a raw secret is absent from
encode() by design:
ApiKey.decode(apiKey.encode()); // ✗ Err(InvalidEntity) — `secret` is required and absent
ApiKey.make(apiKey.encode()); // ✓ Ok(ApiKey){
generated: ["id", "createdAt"], // omitted from createInput; a caller may never send them
immutable: ["id", "createdAt", "slug"], // omitted from updateInput; update() drops them even if smuggled in at runtime
}Both are arrays of field names, and both are keyed off keyof S
(generated) or keyof decoded (immutable), so a typo — immutable: ["slugg"] — is a compile error, not a silently-mutable field.
With no decoded option, the stored ("decoded") shape is identical to the
wire ("encoded") shape. When they differ — a secret that's consumed but never
kept, a value that's derived rather than stored — decoded.omit drops fields
and decoded.add, via the curried add helper, declares computed ones:
import { Entity, add } from "@btravstack/entity";
const Secret = z.string().min(16).brand("Secret");
const Fingerprint = z.string().length(12).brand("Fingerprint");
class ApiKey extends Entity("ApiKey")(
{ id: ApiKeyId, orgId: OrgId, secret: Secret, createdAt: Instant },
{
generated: ["id", "createdAt"],
immutable: ["id", "orgId", "createdAt"],
decoded: {
omit: ["secret"],
add: add({ fingerprint: Fingerprint })((e) => ({
fingerprint: e.secret.slice(0, 12) as z.infer<typeof Fingerprint>,
})),
},
},
) {}add(fields)(fn) is two calls: the first fixes the added fields' schemas, the
second declares how to produce them. e, the parameter of the second call, is
the encoded shape — so an omitted field like secret is still visible to
compute from, even though it never reaches decoded — and the callback's
return type is checked against the declared fields, so fingerprint must
already be branded Fingerprint, not a bare string.
create and decode re-validate only the fields add produced, never
the kept fields (those were already validated once, against encoded, and
re-running a field schema that carries a non-idempotent .transform() would
apply it twice). add's function necessarily returns an unchecked as Brand
cast — constructing a branded value has no other spelling — and re-validating
its output is what makes that cast honest: if add ever produces data its own
declared schema would reject, that is a defect (a bug in domain code), not
ordinary bad input. See Error handling.
An invariant is a rule spanning two or more fields that no single field's schema can express:
class Organization extends Entity("Organization")(
{ id: OrgId, name: DisplayName, createdAt: Instant, trialEndsAt: Instant },
{
generated: ["id", "createdAt"],
immutable: ["id", "createdAt"],
invariants: (d) =>
d.trialEndsAt > d.createdAt
? []
: ["trialEndsAt must be after createdAt"],
},
) {}invariants receives the stored (decoded) data and returns the messages of
the broken rules — an empty array means valid, and it can return more than
one message, so a caller violating two rules at once learns about both:
invariants: (d) => [
...(d.trialEndsAt > d.createdAt
? []
: ["trialEndsAt must be after createdAt"]),
...(d.seatLimit >= d.seatsUsed
? []
: ["seatsUsed must not exceed seatLimit"]),
];It runs before the instance exists, on every entry point — create,
update, make and decode. Because data is immutable once constructed, a
rule that holds at construction holds for the instance's entire lifetime.
Two entities are equal when they are instances of the same entity and their encoded data is deep-equal:
const a = Organization.make(state).getOrThrow();
const b = Organization.make(state).getOrThrow();
a === b; // false — different instances
a.equals(b); // true — same encoded data
a.equals(a.update({ name: other }).getOrThrow()); // false — data differs
someOrg.equals(someApiKey); // false — different entities, even with identical field valuesThe comparison serialises both sides' encode() output, which is what makes
two entities holding equal arrays compare equal — a naive
reference-equality check gets this wrong, because arrays compare by
reference even when their contents match.
Every entity instance carries a non-enumerable _tag — the string passed to
Entity(tag) — installed for pattern matching with unthrown's P.tag(...):
import { match, P } from "unthrown";
match(member)
.with(P.tag("User"), (u) => u.email)
.with(P.tag("ServiceAccount"), (s) => s.label)
.exhaustive();It never reaches the wire. It is absent from every schema, from
encode(), toJSON(), JSON.stringify(entity), Object.keys(entity) and
{ ...entity }. That has a direct consequence for unions: a union that must
survive a JSON round-trip cannot discriminate on _tag — it isn't there
after serialisation. Declare the discriminant as an ordinary domain field
instead:
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 = z.discriminatedUnion("kind", [
User.decoded,
ServiceAccount.decoded,
]);This parses both members, rejects an unknown discriminant, and generates JSON
Schema in both directions with one branch per member — because kind is a
real field on a real ZodObject, not framework metadata layered on top. A
union of the instance surfaces decodes to the right class:
const Instances = z.union([User.instance, ServiceAccount.instance]);
Instances.parse(userRow) instanceof User; // trueand _tag still serves in domain code, exactly as in the match example
above — the two mechanisms solve different problems. A brand is per field
and type-only (it disappears at runtime); the tag is per entity and
runtime-present, which is what makes it matchable.
Every fallible entry point returns Result<T, InvalidEntity>:
class InvalidEntity extends TaggedError("InvalidEntity")<{
readonly entity: string;
readonly issues: readonly string[];
}> {}| Failure | Channel | Why |
|---|---|---|
| schema validation (a field fails its own zod check) | InvalidEntity |
bad input, expected |
a broken invariants rule |
InvalidEntity |
bad input, expected |
add's output failing its own declared schema |
defect | add is pure, total, and typed — a violation is a bug in domain code, not bad caller input |
any of the above, reached through instance |
zod issues, path-prefixed | so a nested entity's failure names the member that failed |
ApiKey.decode({ ...raw, secret: "short" });
// Err(InvalidEntity { entity: "ApiKey", issues: ["Too small: expected string to have >=16 characters"] })
z.object({ owner: Organization.instance }).safeParse({ owner: brokenRow });
// issues: [{ path: ["owner"], message: "trialEndsAt must be after createdAt" }]A Defect — the unexpected-failure channel unthrown's Result reserves
separately from E — is never folded into an ordinary validation issue, even
through instance: an unmodelled bug in domain code stays distinguishable
from bad caller input all the way to the edge.
new SomeEntity(...) does not compile, from outside the package or inside a
consumer's code:
new Organization({ id, slug, name, createdAt }); // ✗ compile errorThe constructor is closed by a module-private unique symbol the package
never exports, so no outside code can produce a value satisfying it — every
instance is built through create/update/make/decode, which means
invariants has run and the stored data is exactly the shape decoded
describes. This is a compile-time-only mechanism (there is no runtime
constructor guard to keep in sync, and none is needed for the type-level
guarantee it gives you), and it survives a published build: the package's
dist/*.d.ts carries the seal, verified by this repository's own CI against
the compiled package, not just the source.
Data fields are locked with Object.defineProperty(..., { writable: false })
per key at construction, and typed Readonly<...> on the instance type, so
mutation is a compile error first and a TypeError only if a consumer casts
around the type system:
org.slug = otherSlug; // ✗ compile error — read-only property
(org as never as Record<string, unknown>).slug = "hacked"; // TypeError: Cannot assign to read only property 'slug'A subclass can still declare its own instance fields (they're set by field initialisers that run after the locked fields are installed):
class OrgWithCache extends Organization {
cachedSummary = "";
}
const org = OrgWithCache.decode(raw).getOrThrow();
org.cachedSummary = "computed"; // ✓ still writable — it isn't declared data
org.encode(); // does NOT include cachedSummary — encode() projects only the declared schema's keysFour generic type-level helpers name each schema by reading it off an entity class, instead of re-declaring the shape by hand:
import type { CreateInput, Decoded, Encoded, Patch } from "@btravstack/entity";
type OrgWire = Encoded<typeof Organization>; // what the wire sends — mapper/request signatures
type OrgState = Decoded<typeof Organization>; // what make() takes — repository signatures
type OrgCreate = CreateInput<typeof Organization>; // what create() takes from a caller
type OrgPatch = Patch<typeof Organization>; // what update() takesThis package reads no clock and generates no id. create's generated
parameter, not an internal crypto.randomUUID()/Date.now() call, is how a
domain-generated value reaches the entity — the rule (which fields the
domain owns, and that a caller may never supply them) lives in the entity
declaration; the values come from whatever your application injects (a
clock port, an id generator), which keeps the entity pure and your tests able
to pass fixed values instead of stubbing globals.
zod, unthrown and @unthrown/standard-schema are peer dependencies,
not bundled dependencies. @btravstack/entity hands back real ZodObjects
and real Results built from your copies of those packages — if this
package pinned its own instead, a consumer would end up with two copies of
zod (or unthrown) in the dependency tree, and identity checks like
result instanceof Result, schema instanceof z.ZodType, or composing this
package's instance schema into the consumer's own z.object({...}) can
silently misbehave across the module boundary between two copies of the same
package.
This is a pnpm + turbo monorepo with a single package, packages/entity
(published as @btravstack/entity).
git clone https://github.com/btravstack/entity.git
cd entity
pnpm install
pnpm build # tsdown, dual CJS/ESM + .d.ts
pnpm test # vitest
pnpm typecheck # tsc --noEmit, including the .test-d.ts compile-error assertions
pnpm lint # oxlint
pnpm format # oxfmtSee CONTRIBUTING.md for the full contribution gate and the commit/changeset conventions.
MIT © Benoit TRAVERS