Skip to content

Repository files navigation

entity

entity

A domain-entity builder for TypeScript, on zod v4 — branded fields, immutable data, sealed construction, and Result instead of throws.

CInpm versionnpm downloadsTypeScriptLicense: MIT

Documentation · Getting started · Reference · Why entity?

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 unthrownResult.

import{z}from"zod";import{Entity}from"@btravstack/entity";constOrgId=z.uuid().brand("OrgId");constSlug=z.string().min(1).brand("Slug");constDisplayName=z.string().min(1).brand("DisplayName");constInstant=z.iso.datetime().brand("Instant");constUpper=z.string().min(1).brand("Upper");classOrganizationextendsEntity("Organization")({id: Entity.field(OrgId,{generated: true,immutable: true}),slug: Entity.field(Slug,{immutable: true}),name: DisplayName,createdAt: Entity.field(Instant,{generated: true,immutable: true}),},{computed: {shout: Entity.computed(Upper,(d)=>d.name.toUpperCase()),},invariants: [Entity.invariant((d)=>d.name.length<=80,"name must be at most 80 characters",),],},){getgreeting(): string{return`Welcome, ${this.name}`;}}

Install

pnpm add @btravstack/entity zod unthrown @unthrown/standard-schema

zod, unthrown and @unthrown/standard-schema are peer dependencies — install all four. (Why.)

A worked example

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.constcreateOrganization=Organization.factory({id: ()=>ids.next(),createdAt: ()=>clock.now(),});// 2. A create use case supplies only the caller's fields.constorg=createOrganization({ slug, name }).getOrThrow();org.greeting;// "Welcome, Acme" — from your class bodyorg.shout;// "ACME" — derived, and re-derived on every construction// 3. Persist. `toJSON()` projects exactly the stored shape — never `_tag`,// never your class-body fields.awaitdb.insert(org.toJSON());// 4. Rehydrate a row. Same entry point as an untrusted payload: validate,// re-derive the computed fields, check the invariants, construct.constloaded=Organization.make(row).getOrThrow();// 5. Update. Returns a NEW entity; invariants re-run; immutable fields are a// compile error, and rejected at runtime if smuggled past it.constrenamed=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.constResponseBody=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});

The surface, at a glance

Schema memberTypeFor
inputZodObjecteverything make() accepts
outputZodObjectstored state and response body
createInputZodObjectcreate request — input minus the generated fields
updateInputZodObjectupdate request — output minus the immutable fields, partial
the classzod schemaparses to an instance; valid as a field, and anywhere zod takes a schema
Entry pointTakesFor
SomeEntity.factory(gens)(input)caller fields onlya create use case
SomeEntity.make(data)everything input describesa row, an event fold, an untrusted import
entity.update(patch)a partial of the mutable fieldsan update use case
entity.toJSON()the stored data, for a write or a response
Field flagEntity.field(schema, …)Meaning
generated{ generated: true }the domain supplies this field, never the caller
immutable{ immutable: true }it never changes after creation
OptionMeaning
computedfields derived from the declared ones, re-derived on every construction
invariantsrules built with Entity.invariant; any failing rule rejects

An entity is final. Fields and behaviour shared by several entities go on a root, Entity.abstract(name)(fields), and extension lives there; a union of entities is a value you name:

abstractclassAccountBaseextendsEntity.abstract("Account")({id: AccountId,label: DisplayName,}){abstractdescribe(): string;// every variant owes this — the compiler checks}classPersonalextendsAccountBase.extend("Personal")({kind: z.literal("personal"),}){overridedescribe(): string{return`personal ${this.label}`;}}// `Business` is declared the same way, on the same rootexportconstAccount=Entity.union("kind",[Personal,Business]);exporttypeAccount=Entity.Instance<typeofAccount>;Account.make(row);// Result<Personal | Business, InvalidEntity>

The const and the type are one declaration in two halves, and both names are needed: the const is what you call, the type is Personal | Business, so P.tag(...) narrows it. A variant is still a real instance of its root, so instanceof narrows to it too and AccountBase stays the annotation for "I only need the shared behaviour".

There is no class form. Putting the union at a base-class position is TS2507 at the declaration, because a class's instance type cannot be a union at all (TS2509). (Why.)

Documentation

btravstack.github.io/entity — built with VitePress from docs/, and organised by the four Diátaxis modes:

Development

See CONTRIBUTING.md for the contribution gate, the commit convention, and how the Node version matrix is chosen.

License

MIT © Benoit TRAVERS

About

A domain-entity builder on zod v4: branded fields, immutable data, sealed construction, and Result instead of throws

Topics

Resources

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages