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
37 changes: 37 additions & 0 deletions .changeset/abstract-roots-and-union-classes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@btravstack/entity": minor
---

Add `Entity.abstract(name)(fields, options?)`, a tagless root that carries shared
fields **and shared behaviour** into every entity extended from it, and make
`Entity.union(...)` return a class so a union can be declared with
`class X extends Entity.union(...) {}` and used as a type. `Entity.Instance<T>`
recovers an entity's or a union's instance type.

A root is a real supertype: `variant instanceof Root` is true, an `abstract`
member on the root is enforced on every variant (`TS2515`), and a
behaviour-only intermediate `abstract class` between the two is picked up. A
union's class body is for **statics** — it has no instances, and as a type it is
the root its members share; `Entity.Instance<typeof X>` is the exact member
union.

**Breaking:** `extend` is no longer on an entity — an entity is final. Wrap the
shared fields in an abstract root and declare both entities as variants of it:

```ts
// before
class Person extends Entity("Person")({ id: Id, name: Name }) {}
class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) {}

// after
abstract class PersonBase extends Entity.abstract("Person")({
id: Id,
name: Name,
}) {}
class Person extends PersonBase.extend("Person")({}) {}
class PersonWithAge extends PersonBase.extend("PersonWithAge")({ age: Age }) {}
```

A root is where behaviour shared by every variant lives, which is what the old
`extend` could not carry: it rebuilt from the declaration alone, so class-body
members had to be written again per extension.
70 changes: 57 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ that are not derivable from there:
shape and reported only `TS4020`. Widen the entity and both report it. So a
band of realistic domain widths fails for consumers and passes here —
which is the band issues #31 and #32 shipped through.
That example keeps its abstract root in `src/root.ts`, exported, rather than
beside its variants: a root reaches a variant's `.d.ts` as a synthesised local
`declare abstract class` when the two share a module and as a **named import**
when they do not, and only the first path was compiled while the root lived in
`index.ts`.
- **A `paths` mapping is not how the emit fixture resolves the package.**
`examples/billing-domain` depends on `@btravstack/entity` as `workspace:*`
and reaches `dist/index.d.mts` through its real `exports`, the way an actual
Expand Down Expand Up @@ -67,7 +72,8 @@ cannot run against it. Measured — the reason is inline in

## Architecture

Ten source modules under `packages/entity/src`, split by what they own:
Twelve source modules under `packages/entity/src` besides `index.ts`, split by
what they own:

- **`entity.ts`** — the builder. `Entity(tag)(fields, options)` derives the
four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`) from
Expand All @@ -80,11 +86,31 @@ Ten source modules under `packages/entity/src`, split by what they own:
`JSON.stringify`, or spread. `toJSON()` is the **only** public projection —
it, `equals` and `update` all route through a module-private `project`, so
there is no second public spelling of the same data. It also carries the
whole public surface: `Entity.computed` / `Entity.union` /
`Entity.InvalidEntity` as expando properties, and every public type in a
whole public surface: `Entity.computed` / `Entity.invariant` /
`Entity.abstract` / `Entity.union` / `Entity.InvalidEntity` as expando
properties, and every public type in a
merged `declare namespace Entity`. Namespace members alias imported types
through `*Src` names deliberately — see the comment there before renaming
one.
- **`base.ts`** — `Entity.abstract(name)(fields, options?)` and the `extend`
that lives on what it returns. A root is tagless, has no `make` and none of
the four schema members; it exists to be extended and to hold the behaviour
every variant shares. `extend` rebuilds a fresh entity from the declaration
record (a `WeakMap` keyed by the class, walked up the _static_ chain so a
user's own intermediate subclass still finds it), then rewires the new
prototype onto the receiver's — which is what makes `variant instanceof Root`
true, picks up a behaviour-only intermediate root, and leaves the entity's own
`toJSON`/`equals`/`update` shadowing anything a root declares under those
names. The rewiring is **instance-prototype only** — one `setPrototypeOf` on
`child.prototype` — and that single fact explains the rest: a root's
`static` members are not inherited (the static chain is untouched), a root's
class-body **field** is typed but never initialised (the variant's generated
base extends nothing, so a root's constructor never runs), and the
construction seal is unaffected. `docs/reference/declaration.md` states all
three; `base.spec.ts` pins them. Options merge per key, child winning, except
`invariants`, which concatenate — so a variant declaring `computed` **drops**
the root's derived fields. Built against a loosened `BuildEntity` passed in from
`entity.ts`, so this module imports no builder and there is no cycle.
- **`equal.ts`** — `deepEqual`, the primitive behind `equals`. Not
`JSON.stringify`: that **threw** on a `bigint` field, compared `Set`/`Map`/
typed-array fields with different contents as **equal**, and reported a
Expand All @@ -99,7 +125,8 @@ Ten source modules under `packages/entity/src`, split by what they own:
passes one `WeakSet` across every field, so a subtree two fields share is
walked once.
- **`types.ts`** — the whole type-level derivation (`OutputOf`,
`CreateInputOf`, `PatchOf`, `UpdateInputShapeOf`, `EntityStatic`), plus
`CreateInputOf`, `PatchOf`, `UpdateInputShapeOf`, `EntityStatic`,
`AbstractEntity`/`RootInstance`/`BehaviourOf`), plus
`Sealed<D>`, the module-private `unique symbol` that makes `new X(...)` a
compile error. Written independently of the builder's body-local values so
`EntityStatic` can serve as the builder's explicit return annotation.
Expand All @@ -110,7 +137,13 @@ Ten source modules under `packages/entity/src`, split by what they own:
makes a schema built from a subclass yield that subclass.
- **`union.ts`** — `Entity.union(discriminant, members)`. Dispatches on the
declared discriminant rather than trying each branch, so a failing member
reports its own issues.
reports its own issues. It returns a **class**, so the idiom is
`class Account extends Entity.union("kind", [Personal, Business]) {}` — a
union's class body is for statics, and its constructor defects. A base
constructor may not return a union (TS2509), so `SoleType` claims the root
the members share and falls back to the empty type when they share none;
`Plain` strips that root's abstractness, which a union could never implement.
`Entity.Instance<typeof Account>` is where the exact member union lives.
- **`shape.ts`** — `OnlyNominal`, the type-level check rejecting unbranded
fields, and `shape()`, which builds the validated field map. Both are
internal; neither is exported from `index.ts`.
Expand Down Expand Up @@ -141,8 +174,15 @@ design — `contract.spec.ts` pins that both ways.
carry a targeted `oxlint-disable` with a reason — several already exist for
`no-catch-all-pattern` where `SchemaIssues` is a single non-union type.
- **Comments recording measurements are regression guards.** Many comments
cite a specific TS diagnostic code (TS2411, TS2526, TS4020, TS4111) or a
measured library behaviour. Verify before "simplifying" them away — the
cite a specific TS diagnostic code (TS2411, TS2425, TS2509, TS2515, TS2526,
TS4020, TS4111) or a measured library behaviour. The four around roots and
unions: a base constructor may not return a union or a `never`-collapsed
intersection (**TS2509** — `SoleType`, and `RootInstance` widening `_tag` to
`string`), a mapped behaviour type turns a method into a property and breaks
a variant's `override` (**TS2425** — `BehaviourOf`, which must stay unmapped),
and abstractness **does** propagate through the intersection (**TS2515**),
which is why a root's `abstract` member binds every variant and why `Plain`
strips it back off for the union. Verify before "simplifying" them away — the
catalog in `pnpm-workspace.yaml` pins `typescript` and `@orpc/zod` to the
exact versions those measurements were taken against, with the reason inline.
- **Type-level behaviour lives in `*.test-d.ts`**, checked by
Expand All @@ -154,17 +194,21 @@ design — `contract.spec.ts` pins that both ways.
library can be "done". Resist convenience aliases.
- **`index.ts` exports `Entity`, and nothing else you write against.** A bare
`computed` or `union` is too generic to take from a consumer's import scope,
so everything hangs off the builder. The sole exception is `BaseInstance` /
`ConstructionKey` / `Sealed`, exported at the top level as well: a downstream
so everything hangs off the builder. The sole exception is the seven
declaration-emit names — `AbstractEntity`, `BaseInstance`, `ConstructionKey`,
`EntityStatic`, `EntityUnion`, `Sealed`, `UnionMember` — 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 — `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
class body. This is runtime-only — TypeScript has no `final`, and
`private`/`protected` constructors were measured to break the declaration
- **Entities are final.** One `extends` is the declaration form; `construct`
defects on anything deeper, and `EntityStatic` carries no `extend`. Behaviour
goes in the entity's own class body. Extension lives on `Entity.abstract`,
which is tagless and can therefore carry a class body into every variant —
`base.ts` above. The ban on a deeper `extends` is runtime-only: TypeScript has no `final`,
and `private`/`protected` constructors were measured to break the declaration
form (TS2675) and the statics (TS2684) respectively.
- **No I/O.** The package reads no clock and generates no id. `create` lives on
a factory (`Entity.factory(generators)` / `factoryAsync`) — a function you
Expand Down
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,36 @@ Organization.make({ ...row, name: "" }).match({
| `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.
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 declared as a class:

```ts
abstract class AccountBase extends Entity.abstract("Account")({
id: AccountId,
label: DisplayName,
}) {
abstract describe(): string; // every variant owes this — the compiler checks
}

class Personal extends AccountBase.extend("Personal")({
kind: z.literal("personal"),
}) {
override describe(): string {
return `personal ${this.label}`;
}
}

// `Business` is declared the same way, on the same root
class Account extends Entity.union("kind", [Personal, Business]) {}

Account.make(row); // Result<Personal | Business, InvalidEntity>
```

A variant is a real instance of its root, so `instanceof` narrows to it, and
`Account` used as a type _is_ that root. `Entity.Instance<typeof Account>` is
the exact member union.
([Why](https://btravstack.github.io/entity/explanation/unions-and-roots).)

## Documentation

Expand All @@ -154,7 +181,7 @@ with VitePress from [`docs/`](./docs), and organised by the four
- **[Tutorial](https://btravstack.github.io/entity/tutorial/getting-started)** — from nothing to a working entity, one step at a time.
- **How-to guides** — [expose an HTTP contract](https://btravstack.github.io/entity/how-to/http-contract) · [persist and rehydrate](https://btravstack.github.io/entity/how-to/persist-and-rehydrate) · [model an aggregate](https://btravstack.github.io/entity/how-to/model-an-aggregate) · [test domain logic](https://btravstack.github.io/entity/how-to/test-domain-logic)
- **[Reference](https://btravstack.github.io/entity/reference/declaration)** — every member, option and type, with signatures. Plus the [generated API reference](https://btravstack.github.io/entity/api/).
- **[Explanation](https://btravstack.github.io/entity/explanation/why-entity)** — why it is built this way: sealed construction, deep immutability, no I/O, why entities are not subclassable.
- **[Explanation](https://btravstack.github.io/entity/explanation/why-entity)** — why it is built this way: sealed construction, deep immutability, no I/O, why an entity is final and a union is a class.

## Development

Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const GUIDE_SIDEBAR = [
{ text: "Immutability", link: "/explanation/immutability" },
{ text: "Why computed re-derives", link: "/explanation/computed-fields" },
{ text: "Tags and identity", link: "/explanation/tags-and-identity" },
{ text: "Unions and roots", link: "/explanation/unions-and-roots" },
{ text: "Errors are values", link: "/explanation/errors-are-values" },
{ text: "Peer dependencies", link: "/explanation/peer-dependencies" },
],
Expand Down
12 changes: 6 additions & 6 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ _why_ the surface is shaped this way, read the
import { Entity } from "@btravstack/entity";
```

`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)
`Entity.computed`, `Entity.invariant`, `Entity.abstract`, `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
[seven declaration-emit type names](/reference/types#the-declaration-emit-names)
a consumer's own `.d.ts` has to be able to write.
Loading
Loading