Skip to content

Emit polymorphic payloads as unions of sealed arms#88

Open
ruudk wants to merge 2 commits into
mainfrom
claude/variant-union-sealed-shapes
Open

Emit polymorphic payloads as unions of sealed arms#88
ruudk wants to merge 2 commits into
mainfrom
claude/variant-union-sealed-shapes

Conversation

@ruudk
Copy link
Copy Markdown
Owner

@ruudk ruudk commented May 28, 2026

Summary

Replace blended array{name: string, 'login'?: string, 'url'?: string, ...} payload shapes for polymorphic GraphQL selections with array{__typename: 'User', name: string, login: string} | array{__typename: 'Application', name: string, url: string} — a union of sealed arms, one per concrete type, discriminated by __typename.

Before

/**
 * @param array{
 *     '__typename': string,
 *     'login'?: string,
 *     'name': string,
 *     'url'?: string,
 *     ...,
 * } $data
 */

After

/**
 * @param array{
 *     '__typename': 'Application',
 *     'name': string,
 *     'url': string,
 * }|array{
 *     '__typename': 'User',
 *     'login': string,
 *     'name': string,
 * } $data
 */

Variant getters lose their array_key_exists guards — PHPStan narrows on __typename literal alone, so the variant subclass's required fields are provably present.

How the planner produces the union

  • Concrete-type fragments (... on User { login }) add a variant arm keyed by the concrete type name.
  • Abstract-type fragments (... on Person { firstName, lastName } where Person is an interface) distribute their fields to every concrete implementor. Abstract type names never appear as arms — __typename only holds a concrete object type at runtime.
  • Schema enumeration — even when only one fragment is written, every concrete type in the parent's union/interface is emitted as an arm (empty body for the unwritten ones). This stops __typename === 'X' from becoming always-true on single-fragment queries against multi-member abstract types.
  • Nested variants... on Employee { role } inside ... on Person contributes role to the Developer / Manager arms (both implement Employee) but not to RegularUser. PayloadShape::addVariant() collapses matching nested variants into the destination so this resolves correctly.
  • Conditional fragments (@include / @skip) demote their contributed variant fields to optional via withFieldsOptional().

Trade-off you signed off on

No explicit "fallback arm" with a wide __typename: string. If the server returns a concrete __typename the client didn't write a fragment for and you didn't include that type in the schema, the type system says "impossible" — but runtime stays safe because the variant getters still return null on mismatched __typename. For closed union schema members this is exact; for open interface implementors it's a small fiction.

Test plan

  • All 179 unit tests pass.
  • PHPStan max passes (no errors).
  • All 39 generated fixtures (tests/*/Generated/, examples/Generated/) regenerated and round-trip via --ensure-sync.
  • tests/Planner/PayloadShapeBuilderTest.php expectations updated for the 8 polymorphic-selection tests.
  • Hand-checked InlineFragments, InlineFragmentTypename, QueryObjectTypename, HooksInUnionVariant, Optimization, ExplicitTypename, Fragments generated outputs.

https://claude.ai/code/session_01WwMs8ZS9JBdXD6ZzwREpzF


Generated by Claude Code

claude and others added 2 commits May 28, 2026 20:12
Inline fragments / fragment spreads on union and interface parents now
register per-variant shapes on `PayloadShape` instead of folding every
variant field into one unsealed shape with `?:` markers. The payload is
emitted as `array{__typename: 'X', ...} | array{__typename: 'Y', ...}`
arms — one per concrete type the parent can hold, with each arm sealed.

Three reinforcing changes make this work:

1. Concrete-type fragments add a variant arm; interface/union fragments
   distribute their fields to every concrete implementor. Abstract type
   names never appear as their own arms since `__typename` only holds a
   concrete object type at runtime.

2. Even when the query only writes a fragment for one of several
   schema-known variants, all concrete types are still enumerated as
   arms (empty bodies for the unwritten ones). This keeps PHPStan's
   `__typename === 'X'` check from being always-true.

3. The variant getters drop the `array_key_exists` guards — PHPStan can
   narrow a union of sealed arms by `__typename` literal alone, so the
   variant constructor's required fields are provably present.

`PayloadShape::addVariant()` resolves nested variants whose names match
the destination, so an `... on Employee { role }` inside an
`... on Person` correctly contributes `role` to the `Developer` /
`Manager` arms but not to `RegularUser`. Conditional fragments
(`@include`/`@skip`) demote their contributed variant fields to
optional via `withFieldsOptional()`.
When a parent's selection set contains both a polymorphic field (with
inline-fragment arms) and a fragment spread that selects the same
polymorphic field at its own path, PayloadShapeBuilder correctly
includes the spread-contributed fields on every arm of the parent's
typed shape. The recursive class generated for the polymorphic field
must mirror that shape — otherwise its sealed arms reject the wider
parent value (PHPStan: "Sealed array shape does not accept array with
extra key ...") and `new Sub($this->data[$field])` fails.

Restricting the enrichment to interface/union-typed fields preserves
fragment-field isolation for concrete-typed fields, whose open shapes
accept extras without any subclass mismatch.
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