Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/heavy-buses-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@btravstack/entity": minor
---

Two correctness fixes, honest `toJSON` typing, and readable errors.

- **Fix: `deepEqual` no longer remembers failed comparisons as equal.** The
cycle guard recorded every pair it entered and never forgot one that
finished `false`, so two `Set`/`Map` fields with plainly different contents
could compare equal once their elements shared a subtree. The guard is now a
stack of in-progress pairs, not a memo.
- **Fix: `deepFreeze` no longer freezes caller-owned values under a union
branch.** The schema walk lost context at `union`, `pipe` and
`intersection` boundaries, so a `z.custom(...)` value nested inside one was
frozen in place — mutating an object the caller still owns. The walk now
carries context through all three.
- **`toJSON()` returns `DeepReadonly<Output>`.** The projection is shallow:
the top-level object is fresh, but nested containers are the instance's own
frozen references, so the previous mutable type let
`toJSON().tags.push(…)` compile and throw at runtime.
- **`InvalidEntity.message` is populated** — `"<entity>: <path>: <message>; …"` —
so a log line or a failed assertion names the entity and the failing fields
instead of printing a blank `Error`. The structured `issues` are unchanged.
- **New `Entity.renderIssue` and `Entity.keysOf`** — the issue helpers an
adapter needs to turn an `InvalidEntity` into a response body, the same ones
the message is built from.
- **A duplicate union discriminant value is a declaration-time defect.**
`Entity.union` previously let the last member win while zod threw lazily at
the first parse; it now fails at the declaration, naming both members.
- **The construction seal's property is named `__useMakeOrFactoryInstead`**, so
the compile error on `new SomeEntity(…)` tells the reader what to do.
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ design — `contract.spec.ts` pins that both ways.
`ConstructionKey` / `Sealed`, exported at the top level as well: a downstream
library compiling with `declaration: true` emits the _underlying_ name, not
the namespace path aliasing it, so hiding them fails the consumer pass with
`TS4020`. That is measured, not assumed — `consumer/index.ts` names every
namespace member for exactly this reason, and an **unused**
`TS4020`. That is measured, not assumed — `examples/billing-domain/src/emit-guards.ts`
names every namespace member for exactly this reason, and an **unused**
`@ts-expect-error` there is a failure signal, not noise.
- **Entities are not subclassable.** One `extends` is the declaration form;
`construct` defects on anything deeper. Behaviour goes in the entity's own
Expand Down
2 changes: 2 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const GUIDE_SIDEBAR = [
items: [
{ text: "Expose an HTTP contract", link: "/how-to/http-contract" },
{ text: "Persist and rehydrate", link: "/how-to/persist-and-rehydrate" },
{ text: "Evolve an entity", link: "/how-to/evolve-an-entity" },
{ text: "Model an aggregate", link: "/how-to/model-an-aggregate" },
{ text: "Test domain logic", link: "/how-to/test-domain-logic" },
],
Expand All @@ -39,6 +40,7 @@ const GUIDE_SIDEBAR = [
text: "Explanation",
items: [
{ text: "Why entity?", link: "/explanation/why-entity" },
{ text: "Branded fields", link: "/explanation/branded-fields" },
{ text: "No I/O, by design", link: "/explanation/no-io" },
{ text: "Sealed construction", link: "/explanation/sealed-construction" },
{ text: "Immutability", link: "/explanation/immutability" },
Expand Down
18 changes: 10 additions & 8 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ Generated from the source with [TypeDoc](https://typedoc.org/) — every exporte
symbol, with its signature and TSDoc.

- **[`@btravstack/entity`](/api/entity/)** — `Entity`, the merged `Entity`
namespace, and the three seal names (`BaseInstance`, `ConstructionKey`,
`Sealed`) the published declarations force out.
namespace, and the six type names (`BaseInstance`, `ConstructionKey`,
`EntityStatic`, `EntityUnion`, `Sealed`, `UnionMember`) the published
declarations force out.

::: tip Looking for prose?
The generated pages document _signatures_. For what each member is **for**, with
Expand All @@ -22,9 +23,10 @@ _why_ the surface is shaped this way, read the
import { Entity } from "@btravstack/entity";
```

`Entity.computed`, `Entity.invariant`, `Entity.union` and `Entity.InvalidEntity`
hang off it as values, and every public type lives in a merged
`declare namespace Entity`. A bare `computed` or `union` would be too generic to
take from a consumer's import scope, so nothing else is exported — with one
measured exception, [`BaseInstance` / `ConstructionKey` /
`Sealed`](/reference/types#the-seal-names).
`Entity.computed`, `Entity.invariant`, `Entity.union`, `Entity.InvalidEntity`,
`Entity.keysOf` and `Entity.renderIssue` hang off it as values, and every
public type lives in a merged `declare namespace Entity`. A bare `computed` or
`union` would be too generic to take from a consumer's import scope, so nothing
else is exported — with one measured exception, the
[six declaration-emit type names](/reference/types#the-declaration-emit-names)
a consumer's own `.d.ts` has to be able to write.
120 changes: 120 additions & 0 deletions docs/explanation/branded-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
title: Branded fields
description: Why every field must be nominal, what counts as nominal, why the compile error is a type name, and the two blessed ways to mint a branded value.
---

# Branded fields

Every field of an entity must be **nominal**: a branded schema, a narrow
literal union, a boolean, or another entity class. A bare `z.string()` is a
compile error naming `DomainFieldMustBeBrandedOrAnEntity`. This is the
package's most opinionated constraint, and it is the one place where it makes
your declaration longer rather than shorter — so it has to earn itself.

## The bug the rule removes

With plain primitives, a domain model is a bag of interchangeable strings.
`findOrg(slug, name)` type-checks with the arguments swapped; a repository
keyed by `userId: string` happily takes an `orgId`; a function returning a
"validated email" returns something the type system cannot tell from the raw
input it started with. Every one of these compiles, and every one of them is a
runtime bug waiting on the right call site — the class of defect the
literature calls _primitive obsession_.

A brand makes the type nominal:

```ts
const Slug = z.string().min(1).brand("Slug");
const DisplayName = z.string().min(1).brand("DisplayName");

declare function findOrg(
slug: z.infer<typeof Slug>,
name: z.infer<typeof DisplayName>,
): void;

findOrg(name, slug); // ✗ compile error — the arguments are swapped
```

At runtime a branded value is the plain primitive — the brand is a phantom
property that exists only in the type. It costs nothing to store, serialise or
compare; it only refuses to be confused with a different string.

The rule is enforced rather than recommended because a brand only pays for
itself when it is unbroken: one bare `string` field is a hole every
unvalidated value in the program can flow through, and the field map is the
one place a library can check the whole perimeter at once.

## What counts as nominal

The check (`OnlyNominal`, applied to the field map) accepts a field whose
inferred type is already non-interchangeable:

- a **branded schema** — `z.string().brand("Slug")`, `z.uuid().brand("OrgId")`,
a branded object, a branded number;
- a **narrow literal union** — `z.enum(["active", "inactive"])`,
`z.literal("user")`: the wide primitive is not assignable to it, so it
cannot be confused with an arbitrary string;
- a **boolean** — two values carry no identity worth branding;
- another **entity class** — an entity is nominal by construction, and the
class is itself a schema.

The check looks through two wrappers — `.optional()` is stripped and one array
level is unwrapped — so `z.array(Customer)`, `Slug.optional()` and
`z.array(Slug).optional()` all pass; the rule applies to the element, not the
container. What it rejects is exactly the interchangeable core: bare
`z.string()`, bare `z.number()`, and any array or optional of those.

## The error is a type name

The rejection type is named `DomainFieldMustBeBrandedOrAnEntity` — a
deliberately sentence-shaped name, because the _name_ is the only part of a
type error guaranteed to survive. A rejection encoded as a tuple of message
strings prints as `& [...]` once TypeScript truncates a long diagnostic,
hiding the advice exactly when the field map is big enough to need it; a name
survives truncation and _is_ the message. The construction seal plays the same
trick: `new SomeEntity(...)` fails on a missing property called
`__useMakeOrFactoryInstead`
([Sealed construction](/explanation/sealed-construction)).

## The cost, and the two blessed patterns

The cost is ceremony: a branded type has no literal syntax, so somewhere a
plain value has to become a branded one. There are exactly two honest ways.

**At a boundary, parse.** The schema is the brand's gatekeeper, so crossing
from untrusted to trusted goes through it — and for entity fields that
boundary already exists: `make` takes `unknown` and validates every field, so
a database row or request body never needs pre-branded values.

```ts
const slug = Slug.parse(raw); // z.infer<typeof Slug> — or safeParse, handled
```

**Where the value is locally proven, cast.** Inside a generator or a
`computed` derivation the value is constructed in place and its validity is
visible in the same expression — and the package keeps the cast honest:
a factory's output goes through `make`'s validation, and a computed field's
output is checked against its own schema on every construction.

```ts
const createOrg = Organization.factory({
id: () => crypto.randomUUID() as z.infer<typeof OrgId>,
});

computed: {
shout: Entity.computed(Upper, (d) => d.name.toUpperCase() as z.infer<typeof Upper>),
}
```

An `as` anywhere else — deep in application code, on a value that came from
outside — is not minting a brand, it is forging one: it silences the exact
check the rule exists to run.

## Related

- [Getting started, step 1](/tutorial/getting-started#_1-brand-your-fields) —
branding in practice.
- [Declaring an entity](/reference/declaration#fields) — the field rules as
reference, including the reserved names.
- [Why entity?](/explanation/why-entity) — the design this constraint belongs
to.
5 changes: 5 additions & 0 deletions docs/explanation/peer-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ So all four are installed together:
```sh
pnpm add @btravstack/entity zod unthrown @unthrown/standard-schema
```

The declared zod range is `^4.3.0`, and the floor is measured rather than
guessed: the full surface typechecks, emits declarations and passes its runtime
assertions on 4.3.0. Nothing here needs a later minor, and monorepos commonly
pin one zod across every package, so the range is kept as wide as it is true.
6 changes: 6 additions & 0 deletions docs/explanation/sealed-construction.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ run and the stored data is exactly what `output` describes.
The seal is a type, not a runtime check, because a runtime guard would mean
throwing — which this package exists to avoid.

The sealing property is named so the compile error carries the fix:
`new SomeEntity(...)` fails with
`Property '__useMakeOrFactoryInstead' is missing …` — the diagnostic tells the
reader what to do, the same trick the field rules play by making
`DomainFieldMustBeBrandedOrAnEntity` the rejection type's name.

Two alternatives were measured and rejected:

- **`private constructor`** → `TS2675: Cannot extend a class 'Base'`. The
Expand Down
115 changes: 115 additions & 0 deletions docs/how-to/evolve-an-entity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
title: Evolve an entity
description: Add, default, rename and retire fields against stored rows — every read goes through make(), so evolution is about what old rows still validate.
---

# Evolve an entity

**Problem:** the model needs a new field, a better name, or one field fewer —
and the database already holds rows in the old shape. Every read goes through
`make()`, which validates against `input`, so the question for each change is
the same: do the old rows still validate?

> Snippets below assume these imports:
>
> ```ts
> import { z } from "zod";
> import { Entity } from "@btravstack/entity";
> ```

## Add an optional field

The safe default. Old rows lack the key, `.optional()` accepts its absence,
and nothing else moves:

```ts
class Organization extends Entity("Organization")({
id: OrgId,
slug: Slug,
note: Note.optional(), // new — old rows simply don't have it
}) {}
```

The nominal-field check looks through `.optional()`, so the wrapper needs no
ceremony. ([Field rules](/reference/declaration#fields).)

## Add a required field

A required field rejects every old row, so something has to supply the value.
Two options, in order of preference:

**Backfill, then require.** Migrate the stored rows first, then tighten the
declaration. The declaration stays honest — the field is required because
every row really has it — and a row that somehow escaped the backfill fails
loudly at `make` instead of silently carrying a filler value.

**Default at the schema.** When there is one correct value for every old row,
put it on the field and skip the migration:

```ts
class Organization extends Entity("Organization")({
id: OrgId,
slug: Slug,
tier: z.enum(["free", "pro"]).default("free"), // old rows read as "free"
}) {}
```

`.default()` substitutes its value when the key is absent, **without** running
it through the schema; `.prefault()` parses the value like any other input —
prefer it when the field transforms or the default should face the same
validation. Either way the value is filled on read and present in `toJSON()`,
so rows heal as they are next written. The trade-off against backfilling: the
database keeps holding rows without the column, so anything querying the
column directly — SQL, an index, another service — does not see the default.
The schema heals reads through `make`; only a backfill heals the rows.

## Rename a field

`make` has no alias mechanism, deliberately — the declaration describes one
shape, not every shape the table has ever had. Renaming is a mapper concern,
at the repository edge: read both, write new.

```ts
// was: shortName — now: slug
type StoredRow = Entity.Output<typeof Organization>;
type LegacyRow = Omit<StoredRow, "slug"> & { readonly shortName: string };

const fromRow = (row: StoredRow | LegacyRow) =>
Organization.make("slug" in row ? row : { ...row, slug: row.shortName });
```

Writes go through `toJSON()` and carry only the new name, so the old column
drains as rows are rewritten. Once a backfill (or time) has emptied it, delete
`LegacyRow` and the mapper's fallback — the mapper is the whole migration
surface, which is the point of routing reads through one.

## Retire a field

Remove it from the declaration. Nothing else is required: `make` ignores
unknown keys, so old rows still carrying the column validate untouched, and
`toJSON()` — which projects exactly `output`'s keys — stops writing it. Drop
the database column whenever convenient.

Retiring is also what makes the **declaration-first** habit safe: a field the
model no longer names cannot be read, so any code still using it fails to
compile at the moment of the change, not in production.

## Computed fields heal themselves

A computed field needs no migration story at all: `make` validates the
declared fields and **re-derives** every computed one, so a row written before
a derivation changed — or before the computed field existed — reads back
correct. See
[Computed columns heal themselves](/how-to/persist-and-rehydrate#computed-columns-heal-themselves)
for the persistence half, and
[Why `computed` re-derives](/explanation/computed-fields) for the reasoning.

## Decide what a failed read means

Every evolution tightens or loosens what `make` accepts, and a row that stops
validating is a real signal, not noise.
[Decide what a read failure means](/how-to/persist-and-rehydrate#decide-what-a-read-failure-means)
covers handling it; while an evolution is rolling out, the
[`InvalidEntity.message`](/reference/errors#message) in the log names the
entity and the failing fields, which is usually enough to tell a missed
backfill from corruption.
16 changes: 12 additions & 4 deletions docs/how-to/http-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ from the model.
>
> ```ts
> import { z } from "zod";
> import { match, P } from "unthrown";
> import { P } from "unthrown";
> import { Entity } from "@btravstack/entity";
> ```

## Use the four `ZodObject` members directly

```ts
const CreateBody = Organization.createInput; // input minus generated
const UpdateBody = Organization.updateInput; // output minus immutable, partial
const UpdateBody = Organization.updateInput; // output minus immutable and computed, partial
const ResponseBody = Organization.output; // stored state
```

Expand Down Expand Up @@ -76,7 +76,11 @@ const Listing = z.object({
## Handle failures at the edge

Issues are structured, so a field-keyed error response is a lookup rather than
a string parse:
a string parse. `Entity.keysOf` normalises an issue's path to plain keys —
Standard Schema permits a segment to be a bare key or a `{ key }` wrapper, and
the helper absorbs both — and `Entity.renderIssue` is the human spelling of one
issue, the same one
[`InvalidEntity.message`](/reference/errors#entity-invalidentity) is built from:

```ts
const result = Organization.make(await request.json());
Expand All @@ -87,7 +91,7 @@ return result.match({
m.with(P.tag("InvalidEntity"), (e) =>
json(422, {
errors: e.issues.map((i) => ({
field: (i.path ?? []).join("."), // "" for a whole-entity rule
field: Entity.keysOf(i).join("."), // "" for a whole-entity rule
message: i.message,
})),
}),
Expand All @@ -99,6 +103,10 @@ return result.match({
});
```

When the response is a flat list of strings rather than field-keyed objects,
`e.issues.map(Entity.renderIssue)` is the whole mapping — `"slug: Too small: …"`
per issue, path prefix included.

An issue with an empty `path` came from `invariants` — a rule spanning the whole
entity rather than one field. That distinction is what lets you decide whether
to attach the message to a form field or to the form.
Expand Down
Loading
Loading