feat: add union(), a union of entities that is itself entity-like - #15
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new public union(discriminant, members) helper to make an “entity-like” discriminated union over entity classes, preserving both contract schemas (input/output) and instance parsing/behavior (instance/make) in a single artifact.
Changes:
- Introduces
union()andEntityUnionas a new public API, exported from the package entrypoint. - Adds runtime and type-level coverage ensuring the union dispatches on the discriminant and preserves member behavior/tags.
- Updates docs and release notes to describe the new union behavior and rationale around using a declared discriminant field.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates union guidance and examples to use the new union() API and explain discriminant vs _tag. |
| packages/entity/src/union.ts | Adds the new union() implementation and EntityUnion type. |
| packages/entity/src/union.test-d.ts | Adds type-level regression tests for correct union narrowing/exhaustiveness and minimum member count. |
| packages/entity/src/union.spec.ts | Updates runtime tests to validate dispatching, error reporting, nesting, JSON Schema generation, and member exposure. |
| packages/entity/src/index.ts | Exports union / EntityUnion from the public entrypoint. |
| .changeset/entity-union.md | Declares a minor release and documents the new API. |
Suppressed comments (1)
packages/entity/src/union.ts:78
byValuesilently overwrites entries when two members share the same discriminant value, making one branch unreachable and shrinking the "expected one of …" list. Also, if a member’sinputschema doesn’t declare the discriminant as az.literal(...), this will throw a cryptic property-access error. Add an explicit construction-time check for a literal discriminant and for duplicate values.
const byValue = new Map<unknown, UnionMember>(
members.map((m) => [
((m.input as z.ZodObject<z.core.$ZodLooseShape>).shape[discriminant] as z.ZodLiteral<string>)
.value,
m,
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
btravers
commented
Aug 7, 2026
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.
Before, a union of entities was a plain zod schema and you chose which half to
lose:
z.discriminatedUnionover theoutputschemas gave a contract butplain data;
z.unionover theinstanceschemas gave instances but no outputJSON Schema. Neither had
make, invariants, or anything else entity-shaped.A correction to my own review
I had listed "you must declare
kind, duplicating the tag" as a defect. Havinglooked properly, the README's existing argument is right and I was wrong:
kindis a domain field — modelled, serialised, queryable — while_tagisframework metadata that deliberately never reaches the wire. Deriving the wire
discriminant from the tag would force wire values to equal class names, which
is worse than the supposed duplication.
uniontakes the declared field, andthe tag keeps doing its own job on whatever comes back.
Dispatch, not try-each
It looks the discriminant up and delegates to that member. A plain
z.uniontries every branch and merges the failures, so one bad email reports every
branch's complaints. Here:
An unrecognised discriminant names the key and lists what was expected. Nested
inside another schema the outer path is preserved:
["member", "email"].Types
InstanceOfreads the member'sinstanceschema rather than itsmake. Firstattempt typed the loosened member with
never, which silently collapsed theresult to
never— the runtime tests all passed whileMember.make(row)wasuseless.
makeis generic in athisparameter and cannot be inferred througha loosened member type;
instancestates the same type plainly.Pinned in
union.test-d.ts: narrowing on the discriminant reaches the rightbranch's fields, the wrong branch's field is a compile error,
P.tagmatchingis exhaustive, and a one-member union does not compile.
Gate
format --check,lint,typecheck(three passes),test(107, 10 files),knip,build— all green.