Skip to content

feat: add union(), a union of entities that is itself entity-like - #15

Merged
btravers merged 2 commits into
mainfrom
feat/entity-union
Aug 7, 2026
Merged

feat: add union(), a union of entities that is itself entity-like#15
btravers merged 2 commits into
mainfrom
feat/entity-union

Conversation

@btravers

@btravers btravers commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
const Member = union("kind", [User, ServiceAccount]);

Member.make(row).getOrThrow(); // User | ServiceAccount — the real class
Member.input;    // discriminated union, one branch per member
Member.output;   // ditto — JSON Schema in both directions
Member.instance; // parses to the member class, and nests as a field
Member.members;  // the tuple, for registries and exhaustiveness

Before, a union of entities was a plain zod schema and you chose which half to
lose: z.discriminatedUnion over the output schemas gave a contract but
plain data; z.union over the instance schemas gave instances but no output
JSON 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. Having
looked properly, the README's existing argument is right and I was wrong:
kind is a domain field — modelled, serialised, queryable — while _tag is
framework 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. union takes the declared field, and
the 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.union
tries every branch and merges the failures, so one bad email reports every
branch's complaints. Here:

Member.make({ ...userRow, email: "not-an-email" })
  → issues: [{ path: ["email"], … }]      // just the member that matched

An unrecognised discriminant names the key and lists what was expected. Nested
inside another schema the outer path is preserved: ["member", "email"].

Types

InstanceOf reads the member's instance schema rather than its make. First
attempt typed the loosened member with never, which silently collapsed the
result to never — the runtime tests all passed while Member.make(row) was
useless. make is generic in a this parameter and cannot be inferred through
a loosened member type; instance states the same type plainly.

Pinned in union.test-d.ts: narrowing on the discriminant reaches the right
branch's fields, the wrong branch's field is a compile error, P.tag matching
is exhaustive, and a one-member union does not compile.

Gate

format --check, lint, typecheck (three passes), test (107, 10 files),
knip, build — all green.

Copilot AI lite review requested due to automatic review settings August 7, 2026 00:14

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

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() and EntityUnion as 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

  • byValue silently 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’s input schema doesn’t declare the discriminant as a z.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.

Comment thread packages/entity/src/union.ts
Comment thread packages/entity/src/union.test-d.ts Outdated
@btravers
btravers merged commit b801fbd into main Aug 7, 2026
13 checks passed
@btravers
btravers deleted the feat/entity-union branch August 7, 2026 00:28
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.

2 participants