Skip to content

Guarantees and compatibility ​

The package is for validated, immutable domain models in zod-based TypeScript backends running on Node. This page is the whole contract in one place: what the compiler enforces, what the runtime enforces, and what is deliberately left to your application design. Each row comes from the source and its tests, not from the explanation pages that argue for it.

Enforced, and where ​

Compile time is checked by tsc and disappears with a cast. Runtime is checked in the running code, whatever the types say. Not covered lists deliberate exclusions, not bugs.

AreaCompile timeRuntimeNot covered
Constructionnew SomeEntity(...) does not compile: the constructor takes a Sealed<D> no outside code can producenone. The constructor validates nothing and runs no invariantCode that casts past the seal gets an unvalidated instance. The seal is a type-system guard, not a runtime trust boundary
Field valuesevery field must be branded, a narrow literal union or another entity; _tag, sameIdentityAs, toJSON and update are reserved namesmake, factory, factoryAsync, update and the class used as a schema all parse against inputmake ignores unknown keys, as z.object does
Invariantsnoneevery rule runs on every construction path, update included; all failing rules reportrules spanning several entities or a database (uniqueness, counts) are yours
Data immutabilitythe instance and toJSON() are DeepReadonlydata fields are non-writable; arrays and plain objects are deep-frozen; a Date is frozen against added properties onlya Date's timestamp (setTime works), Map, Set, typed arrays, and any value a z.custom/z.instanceof field hands through, at any depth. No blanket deep-immutability claim is made
Class-body statenonenone: fields you declare in the class body are ordinary, writable propertiesthey are outside toJSON(), sameIdentityAs and update, and are re-initialised on every new instance
generated flagthe factory's input type omits the fieldthe factory spreads generated values last, so a caller cannot override themmake accepts the field from data: it is the rehydrate path
immutable flagupdate's patch type omits the fieldupdate rejects an immutable, computed or unknown key with an InvalidEntity at that key's pathmake accepts any valid value for it. Nothing compares a row against an earlier one
Computed fieldsfrom is typed against the declared fieldsre-derived on every construction; a stored computed value is ignored rather than trusted
Finalitynone: TypeScript has no finalconstructing through a subclass of an entity is a defect
Roots and variantsredeclaring a root's field is a compile error naming FieldAlreadyDeclaredByTheRootthe same redeclaration throws at declaration timea root's static members are not inherited, and a root's class-body field is typed but never initialised; see Entity.abstract
Errorsentry points return Result<T, InvalidEntity>bad input is an InvalidEntity; a bug in domain code (a throwing computed, a rejecting async generator) is a defect, never an InvalidEntityzod's own methods on the four schema members throw as zod documents, and getOrThrow() throws by name. See Failure channels
IdentitysameIdentityAs exists only on an entity that flags an identity field; an identity field is immutable and a required primitivesame scope (the declaring class or root) and every identity field equal by Object.isno structural equality: compare toJSON() yourself. Class-body state is never compared
No I/Ogenerators are typed per generated fieldthe package reads no clock and generates no id; it calls the generators you bindwhere those generators get their values

The construction row and the immutability exclusions are pinned through a real entity in examples/billing-domain/src/comparison.spec.ts; the rest by the package's own specs and *.test-d.ts files.

Supported versions ​

DependencyDeclaredChecked in CI
Nodeengines: ">=20"22.19, 24 and 26. Node 20 is declared, not proven: the dev toolchain cannot start on 20, and no consumer-side check installs the tarball there yet
TypeScriptno peer range7.0.2 builds and type-checks everything; 5.9.3 compiles a downstream library's declarations against the built package and type-checks what it emits
zodpeer ^4.3.04.6.5. The 4.3.0 floor was measured once, when the range was widened, and is not re-run per change
unthrownpeer ^5.0.05.11.0
@unthrown/standard-schemapeer ^5.0.05.11.0

TypeScript older than 5.9.3 is untested. The package ships both ESM and CJS builds. The three peers are peers so your copies are the ones in use; see Peer dependencies. Adopting the package means adopting their conventions too: zod schemas for fields, unthrown Results for every fallible call.

Runtimes and the browser ​

The package imports no Node built-in. A module that imports an entity bundles for the browser; measured with esbuild on the billing example, which failed on node:util before structural equals was removed. CI still runs the suite on Node only, so other runtimes are untested rather than unsupported.

To share a contract with a client that should not depend on your domain, share data rather than the entity:

  • JSON Schema. Convert createInput, updateInput and output on the server or at build time, and ship the JSON. All four schema members convert in both directions; Expose an HTTP contract has the recipe.
  • A zod-only module. Keep the field vocabulary (the branded schemas) in a module that imports zod and nothing from @btravstack/entity. The client imports that module; the server builds entities from it.

Schema composition ​

UseSupportedWhy
the class as a field of another entityyesthe class is a zod schema through its _zod and ~standard slots
z.object({ owner: SomeEntity }), z.array(SomeEntity), z.optional(...)yesthe same slots; a nested failure keeps its full path
the class wherever a Standard Schema is acceptedyes~standard is delegated
input, output, createInput, updateInput anywhere zod is acceptedyesthey are plain ZodObjects, including .pick, .extend and JSON Schema conversion
z.toJSONSchema(SomeEntity, { io: "output" })nothrows: the class carries a .transform(), which has no output representation
SomeEntity.parse(...), SomeEntity.optional() or any other ZodType methodnoonly the two slots are delegated, so no throwing .parse() sits beside make

The rule behind the table: contracts compose the four plain ZodObjects; domain code composes the class. See Schema members.

Failure channels ​

Three kinds of failure, and they never share a channel:

KindWhat it meansHow it reaches you
validation errorthe data is wrongInvalidEntity in the Result's error channel, with structured issues
defectthe domain code has a bugthe Result's defect channel, kept apart from InvalidEntity
declaration-time throwthe declaration itself is wronga plain throw while the module loads: two union members claiming one discriminant value, a variant redeclaring a root's field

Errors lists which failure takes which channel. Two boundaries are worth knowing:

  • When the class is nested in a schema that zod parses, zod's semantics apply: an InvalidEntity becomes ordinary zod issues, so .parse() throws a ZodError as it would for any field, and a defect is rethrown rather than folded into an issue.
  • An abstract root has no instances. Reaching its constructor through a cast throws.

What stays with your application ​

The package builds one entity at a time. It is compatible with domain-driven design, and it does not do the design for you. It does not:

  • own aggregates. Nesting an entity in another validates the tree; it does not stop other code from loading and changing the child on its own. Which entity is the root and who may change what is yours.
  • draw bounded contexts. Two contexts that need different views of one concept declare two entities; nothing here detects one context reaching into another.
  • enforce valid transitions. update checks that the resulting state is valid, not that the move to it was allowed. "A paid invoice cannot go back to draft" needs the previous state, so it belongs in a method or a use case, or in a separate entity per state; see Number without gaps.
  • infer identity. sameIdentityAs compares only the fields you flag identity; nothing guesses an id convention, and there is no structural comparison of whole states.
  • check anything across entities or storage: uniqueness, optimistic concurrency, authorisation.

For how the same model looks in plain zod and in Effect, see Compared with zod and Effect.

Released under the MIT License.