feat!: a union is a value, not a class - #58
Merged
Conversation
- 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.
There was a problem hiding this comment.
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.unionto return a non-constructable value (nonewsignature) and rely onEntity.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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #57.
A union is a value, not a class. The declaration is the pair:
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)yieldedCard | BankTransfer, whilePayment-as-a-type was the root with_tagwidened tostring. 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:
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>isCard | 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/outputas realz.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/discriminantfor 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
__baseis removed fromEntityStaticandUnionMember. A phantom nobody writes, but part of the emitted public surface.SoleType,SharedBase,PlainandUnionToIntersectionwent with it.minor, per 0.x.Two results reported as measured, not as hoped
__basewere 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._zod/~standardkeptObject.keys, spread andJSON.stringifyout of zod's internals. The first two hold; the third does not —input/outputare themselves enumerableZodTypes, so stringify walks the whole schema graph regardless. The true half is now pinned by a test rather than asserted.Test plan
TS2507at the declaration pinned by a used@ts-expect-error; verified load-bearing by removing the directive and observing the code_tagand the declared discriminant, each verified by breaking the narrowing and observingTS2322— the first version of these assertions was tautological and was caught in review_zod/~standardnon-enumerability pinned by a test, verified by flippingenumerableand watching it failexamples/billing-domainconverted, 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.tson 5.9.3 with no--skipLibCheck) cleanformat --check·lint·typecheck·test(179) ·knip·build— green in CI orderextends Entity.union, and noSoleType/SharedBase/UnionToIntersection/__base, anywhere outside the changeset's own migration example🤖 Generated with Claude Code