Skip to content

feat: add t.variant for union dispatch when members share a typename - #49

Open
jimmy-phantom wants to merge 4 commits into
mainfrom
variant-union-dispatch
Open

feat: add t.variant for union dispatch when members share a typename#49
jimmy-phantom wants to merge 4 commits into
mainfrom
variant-union-dispatch

Conversation

@jimmy-phantom

Copy link
Copy Markdown
Collaborator

Problem

The typename does two jobs: entity identity (the [typename, id] cache key) and union dispatch. When two union members share a typename, dispatch silently collapses to the last member:

class ImagePost extends Entity {
  __typename = t.typename('Post');
  id = t.id;
  kind = t.const('image');
  url = t.string;
}

class GalleryPost extends Entity {
  __typename = t.typename('Post');
  id = t.id;
  kind = t.const('gallery');
  images = t.array(t.entity(ImagePost));
}

// every payload parses as GalleryPost; image posts fail validation and are
// silently dropped from arrays, live membership, and mutation events
t.union(t.entity(ImagePost), t.entity(GalleryPost));

Change

t.variant(value) marks the tag field. Identity stays on the typename; dispatch uses the variant:

class ImagePost extends Entity {
  __typename = t.typename('Post'); // identity: cache key stays [Post, id]
  id = t.id;
  kind = t.variant('image');       // dispatch: selects the shape
  url = t.string;
}
  • unions and t.liveArray dispatch on (typename, variant)
  • live collections route events to the def matching the entity's variant
  • registry merge treats the variant field as an enum of the variants' values
  • t.variant validates like t.const and is not part of the cache key

Behavior changes (hence minor)

  • Duplicate typenames in a union without variants now throw at definition time, instead of silent last-wins. Same for a duplicate (typename, variant) pair, mixed variant and non-variant members, and conflicting variant fields.
  • t.liveValue over same-typename defs without variants routes events to the first def the entity satisfies, instead of always the last def.

Verification

  • 17 new tests in variant-unions.test.ts: definition guards, parse dispatch, single-variant entity arrays, live membership per variant, liveValue multi-def routing
  • full suite: 1264 passed, 2 skipped; tsc --noEmit clean; production build smoke-tested (variant dispatch works, duplicate guard throws)
  • docs: new "Unions with Shared Typenames" section in core/types

🤖 Generated with Claude Code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ptescher
ptescher previously approved these changes Aug 6, 2026

@jimmy-phantom jimmy-phantom left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Automated review (Claude Code)

The mechanism holds up well on the primary paths: I verified parse dispatch, the definition-time guards, the registry enum merge, and per-variant live routing all behave as the PR and changeset describe, and the full unit suite passes. The inline comments cover one real bug in union composition (shared VariantGroup mutation), one routing inconsistency on the delete path, and one unreconciled runtime case (variant flips), each reproduced with a focused test.

Two smaller gaps live in code the diff does not touch:

  • packages/fetchium/src/testing/auto-generate.ts: generateUnion only picks shape entries that are instanceof ValidatorDef, so VariantGroup entries are skipped and a variant-union field generates {} (verified), which cannot parse when the field is required.
  • buildConstraintFieldRefs in packages/fetchium/src/typeDefs.ts keys per-class constraints by typename, so constraints: [[TextPost, {...}], [ThreadPost, {...}]] silently keeps only the last entry when the classes share a typename. Variant members are the first supported way to put two classes with one typename in the same liveArray, which makes this reachable.

Comment thread packages/fetchium/src/typeDefs.ts Outdated
Comment thread packages/fetchium/src/LiveCollection.ts Outdated
Comment thread packages/fetchium/src/LiveCollection.ts
…tion, generator support

Review findings on the t.variant PR:

- Nested-union merging copied VariantGroup entries by reference, so composing
  a variant union into a larger union mutated the inner union, and merging two
  variant unions threw. Group handling is now centralized in
  addVariantDefToUnion, which only creates groups owned by the union being
  built, so aliasing is impossible by construction and group-into-group
  merging gets the same duplicate checks.
- Delete events resolved to the first def per typename instead of the def the
  entity satisfies, handing onDelete a different class than onCreate received.
- generateUnion produced {} for variant-union fields.
- Docs: the variant is fixed for the lifetime of an entity; a mutable tag is a
  state field, not a variant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jimmy-phantom

Copy link
Copy Markdown
Collaborator Author

From the review summary: generateUnion now handles variant groups (446333c, with a test). The per-class constraint collapse in buildConstraintFieldRefs predates this PR; constrained variant unions remain a known limitation for a follow-up.

@jimmy-phantom jimmy-phantom left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Automated review (Claude Code)

Re-review of the fix round: the centralized addVariantDefToUnion merge, parse dispatch, delete-path resolution, and generator changes all check out. One new verified defect in live collection event routing, detailed inline.

Comment thread packages/fetchium/src/LiveCollection.ts Outdated
satisfiesDef was presence-based, so a variant tag's value never participated
in event gating: a single-def binding accepted any sibling variant with the
same field profile, and the multi-def fallback matched variants the binding
never declared. A VariantSet field now satisfies only on a value match, which
also makes entity-array reads variant-aware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@jimmy-phantom jimmy-phantom left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Automated review (Claude Code)

Follow-up pass over the round-3 changes and their consumers. Two new issues, both verified with focused repros: delete events bypass the new variant gating (corrupts t.liveValue reducer state), and the dev-mode merged-def compatibility check rejects structurally identical variant union fields.

Comment thread packages/fetchium/src/LiveCollection.ts Outdated
Comment thread packages/fetchium/src/typeDefs.ts
…ructurally

Two follow-ups to the variant gating work:

- Deletes bypassed the gate added for creates and updates: resolveEventDef
  short-circuited a single variant def without checking the tag, and the
  delete branch delivered defs[0] unconditionally, so liveValue onDelete
  reducers fired for sibling variants. Deletes now skip when the entity's tag
  names a variant the binding does not declare; id-only deletes still route.
- fieldTypesCompatible had no VariantGroup branch, and since every union owns
  fresh groups, two structurally identical variant-union fields compared
  false and ValidatorDef.merge threw in dev on valid responses. Groups now
  compare structurally: same variant field, same keys, compatible members.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jimmy-phantom
jimmy-phantom requested a review from a team August 11, 2026 18:32
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