Skip to content

feat!: abstract roots and union classes - #46

Merged
btravers merged 12 commits into
mainfrom
worktree-feat-abstract-roots-and-union-classes
Aug 8, 2026
Merged

feat!: abstract roots and union classes#46
btravers merged 12 commits into
mainfrom
worktree-feat-abstract-roots-and-union-classes

Conversation

@btravers

@btravers btravers commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #45.

The problem

A domain type that is naturally a discriminated union — a shared base, two variants, a union over the discriminant — was worse to model with this library than a plain entity. The issue reports three symptoms; the third is the one that changes how you model:

symptom before
a union cannot use the class idiom TS2507EntityUnion had no construct signature
a union has no instance type only a hand-maintained InstanceType<typeof A> | InstanceType<typeof B> alias, checked by nothing
extend drops class-body members shared behaviour had nowhere to live but duplicated per variant, or as a free function

What the design turns on

Six measurements, taken before writing anything. Each is now a comment in the shipped source, because they are the reason the code looks like this:

measured
M1 TS2509 — a base-constructor expression may not return a union, so Account-as-a-type can never be Personal | Business
M2 a tagged root cannot carry behaviour: "Account" & "Personal" reduces to never, then TS2509 again
M3 TS2425 — any mapped subtraction (Omit, key-remapping) turns methods into function-typed properties, so a variant implementing an abstract method fails
M4 a tagless root has nothing to collide with, so the receiver's instance type intersects unmapped and methods survive
M5 a root may expose toJSON/equals/update — the intersection produces overloads and the child's win
M6 abstract is unenforceablewrong, see below

M1 is why a union's class body is for statics. M2–M4 are why the root is tagless.

The API

abstract class AccountBase extends Entity.abstract("Account")(
  { id: AccountId, label: Label },
  { immutable: ["id"] },
) {
  abstract describe(): string;
  get slug(): string { return this.label.toLowerCase(); }
}

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

class Account extends Entity.union("kind", [Personal, Business]) {}
type AnyAccount = Entity.Instance<typeof Account>;   // Personal | Business
  • Entity.abstract(name)(fields, options?) — tagless, make-less, no factory, none of the four schema members. Its _tag is string, and string & "Personal" reduces to "Personal" in the variant, which is what keeps M2 from biting. The name labels its defect message and never reaches an instance.
  • extend rewires the new base's instance prototype onto the receiver's, so variant instanceof AccountBase is true and a behaviour-only intermediate abstract class between the two is picked up for free.
  • Entity.union(…) returns a class. Account as a type is the members' shared root — or the empty type when they do not share one, via SoleType, which is what keeps M1 from firing while refusing to claim a supertype the members do not have. Account.make(row) still returns the exact Result<Personal | Business, InvalidEntity>.
  • Entity.Instance<T> collapses the hand-maintained alias to one line that cannot drift.

M6 was wrong, and the correction is good news

The design spec claimed abstract members could not be enforced on variants, and prescribed implements SomeInterface as a workaround. That measurement was taken against a type-literal construct signature, which carries no abstractness. The shipped BehaviourOf<This> yields the real class type, and TypeScript propagates abstractness through the intersection:

TS2515: Non-abstract class 'Forgot' does not implement inherited abstract member
describe from class 'BaseInstance<…> & … & AccountBase'.

So abstract describe(): string on a root is a compiler-enforced obligation on every variant. implements ships nowhere. Pinned by an @ts-expect-error in base.test-d.ts.

The union deliberately strips that abstractness (Plain<T> in union.ts) — it has no instances, so inheriting a root's abstract members would demand implementations that could never run.

What a root does not carry

The rewiring is instance-prototype only, and three consequences follow. All are documented in reference/declaration.md and pinned by tests:

static members not inherited — a compile error, not a crash
class-body fields typed but never initialised: the root's constructor never runs. Measured — silent at the declaration, TypeError at first read. Use a getter
toJSON / equals / update the entity's own prototype sits above the root's, so a root can call them but never override them

A variant declaring computed also replaces the root's, like generated and immutable — only invariants concatenate. That one drops a derived column rather than a rule, so it is now named explicitly.

Breaking

extend is no longer on an entity — an entity is final. This is the direct consequence of M2/M3: a concrete entity cannot carry behaviour into its extension at the type level, and a runtime that carried what the type did not would be worse than not carrying it. Rather than ship two extends with different fidelity, there is one, on the only receiver where it works.

// 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 }) {}

minor, per 0.x. The changeset carries the migration; every fenced example in it was compiled before shipping.

The consumer gate

EntityStatic gains a sixth type parameter carrying the root's instance type, which is exactly the TS7056 band issues #31 and #32 shipped through. So it was measured rather than assumed:

  • +16 extra branded fields on the widest variant still emit clean on 5.9.3. Comfortable margin.
  • Naming ConstructedInstance<Tag, S, A, I> & B in the two zod slots, instead of spelling the intersection out twice more, is the headroom the parameter spends. Recorded in types.ts.
  • examples/billing-domain is now four modules, so a root crossing a module boundary is under the two-compiler gate for the first time. Verified: index.d.ts opens with import { BillingDocumentBase } from "./root.js" rather than a synthesised local declare abstract class.
  • Organization stays a plain, rootless entity — it is what keeps SoleType's Record<never, never> branch exercised in emit.

Test plan

  • base.spec.ts / base.test-d.ts — fields and options carry over, shared behaviour runs on a variant, instanceof the root, behaviour-only intermediate root, the root has no instances, the seal still refuses a bare subclass, and the three "does not carry" cases above
  • The sibling-variant equality pin proved discriminating: with the instanceof Base guard removed the test fails; restored, it passes. The shape it replaced passed guardless, so it pinned nothing
  • union.spec.ts / union.test-d.ts — the class idiom, Account-as-a-type, the exact member union under P.tag, the empty-type fallback for members from different roots, and every pre-existing test untouched
  • extend.spec.ts / extend.test-d.ts deleted, coverage mapped test-by-test into base.* first; the one pin that inverts is "extend carries declarations, not class-body members", which is the behaviour this change reverses
  • format --check · lint · typecheck · test · knip · build — green in CI order, uncached, including the 5.9.3 consumer pass
  • Docs: new explanation/unions-and-roots.md, reference and both how-tos reworked, eight files carrying stale extend references found and fixed

One thing I could not reproduce

A review reported that an unexported cross-module root breaks consumer emit with TS4020 through a branded field. It does not reproduce, and the shape may not be expressible: extend is a call on the value, so a root another module extends must be exported. No unmeasured diagnostic was written into the docs. The file split was kept anyway, on its own merit — it puts the cross-module emit path under the gate.

🤖 Generated with Claude Code

btravers added 12 commits August 8, 2026 17:23
…ule boundary

The emit fixture only ever compiled a root declared beside its variants,
where TypeScript synthesises a local `declare abstract class` for it. Split
into vocabulary / organization / root / index so the two-compiler pass also
covers the path where the root's instance type has to be named across a
module boundary. The widths in `index.ts` are untouched: the emitted
`Invoice_base` still expands the thirty-member dunning enum inline.
Three gaps: a root's class-body field is never initialised and its statics
are not inherited (`extend` rewires the instance prototype only), and a
variant declaring `computed` replaces the root's derived fields rather than
adding to them. Plus the three cheap ones the review named — the shadowing
test asserted only `equals`, `Business.describe()` was never called, and
nothing replaced the deleted `extend.spec.ts` assertion that two variants
with matching data are not equal.
… block

Findings the review named. A root's class-body fields are never initialised
and its statics are not inherited — `extend` rewires the instance prototype
only — so `declaration.md` now says both, alongside where a root goes and
which options replace rather than merge (`computed` included). `union.ts`'s
module doc still taught the removed value form and was orphaned between two
adjacent JSDoc blocks, so `union` rendered undocumented; it now sits on the
function and shows the class form. Plus the smaller ones: `__base` is the
root's *instance* type, a union of members from different roots shares
nothing even when the roots are related, an intermediate cannot add fields,
and CLAUDE.md's module count.
The successor to extend.spec.ts's "even with matching data" compared
Personal and Business, which differ in `kind` — so `deepEqual` returned
false before `equals`' `instanceof Base` guard mattered, and the test
passed with the guard deleted. Two variants of one root adding the same
field under the same schema, with identical data, leave the guard as the
only thing that can say no. Also pins the `private` field case the docs
claim, and corrects three comments the split left stale: emit-guards.ts
named `index.ts` for widths now in `vocabulary.ts`, and `extend`'s
published JSDoc still said it inherits "the class body".
Copilot AI lite review requested due to automatic review settings August 8, 2026 17:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR evolves @btravstack/entity’s declaration model to better support discriminated unions by introducing tagless abstract roots for shared fields/behaviour and by making Entity.union(...) return a class (so unions can use the same “class extends …” idiom as entities). It also makes entities explicitly final by removing .extend() from concrete entities, consolidating extension onto abstract roots.

Changes:

  • Add Entity.abstract(name)(fields, options?) backed by a new base.ts implementation that rebuilds variants from recorded declarations and rewires instance prototypes for true instanceof narrowing + shared behaviour.
  • Change Entity.union(...) to return a class with a sealed (defecting) constructor, a shared-root construct signature for class X extends Entity.union(...) {}, and a new Entity.Instance<T> helper for exact instance unions.
  • Update/replace tests, examples, docs, and changeset/migration guidance to reflect “entities are final; roots are extensible; unions are classes”.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated no comments.

Show a summary per file
File Description
README.md Updates public-facing quick overview to “entities are final”, introduces roots + union class idiom.
packages/entity/src/union.ts Makes union() return a class; adds shared-root construct signature + __instance carrier and runtime constructor defect.
packages/entity/src/union.test-d.ts Adds d.ts coverage for union-as-class typing and Entity.Instance on unions.
packages/entity/src/union.spec.ts Adds runtime coverage for union-as-class dispatch, schema composition, and “no instances”.
packages/entity/src/types.ts Adds RootInstance, BehaviourOf, AbstractEntity, updates EntityStatic with root-behaviour parameter + __base/__instance, removes entity .extend.
packages/entity/src/index.ts Exports AbstractEntity at top-level for declaration emit correctness.
packages/entity/src/extend.test-d.ts Deleted (extension semantics moved under abstract-root tests).
packages/entity/src/extend.spec.ts Deleted (coverage migrated to base.*).
packages/entity/src/entity.ts Integrates declaration recording via record(), wires Entity.abstract, adds Entity.Instance namespace type.
packages/entity/src/entity.test-d.ts Adds type-level assertion that concrete entities no longer have .extend.
packages/entity/src/base.ts New module implementing abstract roots + root .extend() rebuilding and prototype rewiring.
packages/entity/src/base.test-d.ts New d.ts tests for root/variant typing, abstract enforcement, and Entity.Instance on entities.
packages/entity/src/base.spec.ts New runtime tests for root behaviour inheritance, prototype semantics, option inheritance, and “does not carry” cases.
packages/entity/README.md Mirrors root/union-class guidance for package-local README.
examples/billing-domain/src/vocabulary.ts Splits vocabulary into its own module (emit fixture + clarity).
examples/billing-domain/src/root.ts Adds exported abstract root module to exercise cross-module emit path.
examples/billing-domain/src/organization.ts Extracts standalone entity into its own module.
examples/billing-domain/src/index.ts Reworks billing document declarations onto root + variant model; union becomes a class.
examples/billing-domain/src/index.spec.ts Adds tests asserting root behaviour, shared invariants, and per-variant overrides.
examples/billing-domain/src/emit-guards.ts Extends emit fixture to cover Entity.Instance, Entity.Abstract, and root module split behavior.
docs/typedoc.json Updates intentionally-not-exported list for newly introduced internal helper types.
docs/reference/types.md Documents Entity.Instance and updates the declaration-emit type-name list.
docs/reference/declaration.md Rewrites reference around Entity.abstract, root .extend, union-as-class, and “entity is final”.
docs/how-to/model-an-aggregate.md Updates how-to to model unions via roots + union class; emphasizes union body statics-only.
docs/how-to/http-contract.md Updates union example to class form.
docs/how-to/evolve-an-entity.md Adds guidance for splitting one entity into root + variants.
docs/explanation/unions-and-roots.md New explanation page capturing the TypeScript constraints and resulting API shape.
docs/explanation/sealed-construction.md Updates terminology and guidance to “entity is final” and roots as the extension point.
docs/examples/index.md Updates example descriptions to mention roots/variants.
docs/examples/billing-domain.md Updates walkthrough to new module split + root/variants + union-as-class.
docs/api/index.md Updates API intro to include Entity.abstract and the updated declaration-emit name count.
docs/.vitepress/config.ts Adds “Unions and roots” to the explanation sidebar.
CLAUDE.md Updates repository guidance/architecture notes to include roots + union class semantics.
.changeset/abstract-roots-and-union-classes.md Adds changeset documenting new API and the breaking move of .extend off entities.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@btravers
btravers merged commit a885057 into main Aug 8, 2026
14 checks passed
@btravers
btravers deleted the worktree-feat-abstract-roots-and-union-classes branch August 8, 2026 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Union members are second-class: no construct signature, no instance type, no shared behaviour

2 participants