Skip to content

feat!: a union is a value, not a class - #58

Merged
btravers merged 7 commits into
mainfrom
worktree-fix-union-no-class-form
Aug 9, 2026
Merged

feat!: a union is a value, not a class#58
btravers merged 7 commits into
mainfrom
worktree-fix-union-no-class-form

Conversation

@btravers

@btravers btravers commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes #57.

A union is a value, not a class. The declaration is the pair:

export const Payment = Entity.union("method", [Card, BankTransfer]);
export type  Payment = Entity.Instance<typeof Payment>;

export function describe(p: Payment): string {
  return p._tag === "Card" ? p.last4 : p.iban; // narrows, on either discriminant
}

What was wrong

class Payment extends Entity.union("method", [Card, BankTransfer]) {} — the spelling the README led with — typed as the members' shared root, never as the member union. Two true statements pointed in opposite directions: Payment.make(row) yielded Card | BankTransfer, while Payment-as-a-type was the root with _tag widened to string. So it could not narrow, and it failed late — the declaration compiled, and the error surfaced at the first call site touching a member-only field, possibly in another package.

Why it could not be fixed in place

A class's instance type can never be a union. Measured: TS2509: Base constructor return type 'Card | BankTransfer' is not an object type or intersection of object types with statically known members. That is not an implementation gap — it is why the class form typed as the root in the first place, and no version of it could have narrowed.

The class form's type was also always redundant: to extend a root you must declare it, so the root is always nameable. The union class's type was either exactly that root or the empty type. Statics were its only unique value, and those are plain functions:

export const parsePayment = (row: unknown) => Payment.make(row);

Removing the construct signature moves the failure to the declaration — TS2507: Type 'EntityUnion<"kind", readonly [typeof Personal, typeof Business]>' is not a constructor function type — which is what #57 asked for. The trap becomes unwritable rather than documented.

The question #57 actually asked

After this change Entity.Instance<typeof Payment> is Card | BankTransfer, so "why not just write the union by hand?" is sharper than before and now has an answer on the reference page. The value adds: make(unknown) dispatching on the declared discriminant over untrusted input; being a zod schema, so it nests as a field; input/output as real z.discriminatedUnions for contracts and JSON Schema both ways; per-member failure reporting rather than every branch's; a declaration-time defect when two members claim one discriminant value; members/discriminant for registries; and a derived type that cannot drift when a third variant is added — which a hand-written alias does.

Breaking — two items, separately named in the changeset

  1. The class form is gone. Migration is the const+type pair; class-body statics become plain functions.
  2. __base is removed from EntityStatic and UnionMember. A phantom nobody writes, but part of the emitted public surface. SoleType, SharedBase, Plain and UnionToIntersection went with it.

minor, per 0.x.

Two results reported as measured, not as hoped

  • Emitted declarations are flat, not smaller: 25,626 B against a 25,623 B baseline. The plan predicted a shrink. It did not happen, and the reason holds up — the construct signature and __base were already banked into the type, and the const+type pair adds a declaration line where the class form had one. Nothing in the docs or changeset claims a reduction.
  • A comment asserting a false measurement was caught in final review and corrected. It claimed non-enumerable _zod/~standard kept Object.keys, spread and JSON.stringify out of zod's internals. The first two hold; the third does not — input/output are themselves enumerable ZodTypes, so stringify walks the whole schema graph regardless. The true half is now pinned by a test rather than asserted.

Test plan

  • TS2507 at the declaration pinned by a used @ts-expect-error; verified load-bearing by removing the directive and observing the code
  • Narrowing pinned positively on both _tag and the declared discriminant, each verified by breaking the narrowing and observing TS2322 — the first version of these assertions was tautological and was caught in review
  • The root's behaviour reaching a union's instance type restored as a type-level pin (it was only exercised at runtime, a different guarantee)
  • _zod/~standard non-enumerability pinned by a test, verified by flipping enumerable and watching it fail
  • Every test that lost its subject is either rewritten against the value form or deleted with its reason recorded; one test added mid-review was dropped again once shown to duplicate an existing one
  • examples/billing-domain converted, its spec untouched and green; the four-step consumer gate (emit on 7.0.2 and 5.9.3, then a type-check of the emitted .d.ts on 5.9.3 with no --skipLibCheck) clean
  • format --check · lint · typecheck · test (179) · knip · build — green in CI order
  • Acceptance greps: no extends Entity.union, and no SoleType/SharedBase/UnionToIntersection/__base, anywhere outside the changeset's own migration example
  • Renamed heading carries no em dash (measured VitePress anchor trap); new slug verified in the built HTML and both inbound links retargeted

🤖 Generated with Claude Code

- rewrite union()'s stale class-form JSDoc for the value idiom
- make the Entity.Instance narrowing test-d assertions non-tautological
- fix TS2509 -> TS2507 in the "no class form" test comment
- restore non-enumerability of the union's _zod/~standard slots
- narrow the return cast with a satisfies check on the real members
- add a test for a union composed as a field of another entity
Five scoped fixes from the whole-branch review: a dangling AnyMember
reference in the aggregate how-to, a false JSON.stringify claim in
union.ts's non-enumerability comment (corrected and now guarded by a
test), a redundant object spread before Object.defineProperties, a
test-d.ts fixture renamed from Payment to Account to match the rest of
the repo, and dead test-d.ts scaffolding replaced with a restored
type-level pin on a union's inherited root behaviour.
Copilot AI lite review requested due to automatic review settings August 9, 2026 22:37

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 removes the misleading “union as a class” declaration form and makes Entity.union(...) a pure value API, so consumers must use the const + Entity.Instance<typeof ...> pair (which correctly types as the member union and narrows as expected). It also removes the __base phantom surface that only existed to support the former class form, and updates docs/examples/tests accordingly.

Changes:

  • Refactor Entity.union to return a non-constructable value (no new signature) and rely on Entity.Instance<typeof Union> for the member union type.
  • Remove __base (and the associated type machinery) from the public type surface.
  • Update README/docs/examples and type-level/runtime tests to the new idiom and to pin the intended narrowing behavior.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
README.md Updates the top-level docs to present unions as const + type instead of a class form.
packages/entity/src/union.ts Implements the value-only union return shape and removes the class/constructor path.
packages/entity/src/union.test-d.ts Replaces class-form typing assertions with TS2507 pin + narrowing assertions for the value form.
packages/entity/src/union.spec.ts Updates runtime tests to use the value form and adds an enumerable-keys regression test.
packages/entity/src/types.ts Removes __base from EntityStatic (public surface) while keeping __instance as the Entity.Instance carrier.
packages/entity/README.md Mirrors the README union declaration changes for the package-level README.
examples/billing-domain/src/index.ts Migrates the billing example union to the value form (const + type).
docs/typedoc.json Removes SharedBase from the TypeDoc exclusion list after type removals.
docs/reference/types.md Updates Entity.Instance reference docs for the new union value idiom and anchor.
docs/reference/declaration.md Updates union declaration docs and adds guidance on when to declare a union vs. write a manual union type.
docs/how-to/model-an-aggregate.md Migrates union examples and rewrites guidance around “statics” to “entry points beside the const”.
docs/how-to/http-contract.md Migrates union example used for request-body contracts.
docs/explanation/unions-and-roots.md Reframes the explanation around “no class form” and TS2507 at the declaration site.
docs/examples/billing-domain.md Migrates the example and updates the narrative to match the value form.
CLAUDE.md Updates repository architecture notes to reflect union’s value-only form and its rationale/tests.
.changeset/union-no-class-form.md Adds a changeset documenting the breaking removal of the class form and __base.

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

Comment thread packages/entity/src/union.ts Outdated
@btravers
btravers merged commit 1e599b8 into main Aug 9, 2026
13 checks passed
@btravers
btravers deleted the worktree-fix-union-no-class-form branch August 9, 2026 22:44
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 class form types as the abstract root and cannot narrow to a member

2 participants