Emit polymorphic payloads as unions of sealed arms#88
Open
ruudk wants to merge 2 commits into
Open
Conversation
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.
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.
Summary
Replace blended
array{name: string, 'login'?: string, 'url'?: string, ...}payload shapes for polymorphic GraphQL selections witharray{__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
After
Variant getters lose their
array_key_existsguards — PHPStan narrows on__typenameliteral alone, so the variant subclass's required fields are provably present.How the planner produces the union
... on User { login }) add a variant arm keyed by the concrete type name.... on Person { firstName, lastName }wherePersonis an interface) distribute their fields to every concrete implementor. Abstract type names never appear as arms —__typenameonly holds a concrete object type at runtime.__typename === 'X'from becomingalways-trueon single-fragment queries against multi-member abstract types.... on Employee { role }inside... on Personcontributesroleto theDeveloper/Managerarms (both implementEmployee) but not toRegularUser.PayloadShape::addVariant()collapses matching nested variants into the destination so this resolves correctly.@include/@skip) demote their contributed variant fields to optional viawithFieldsOptional().Trade-off you signed off on
No explicit "fallback arm" with a wide
__typename: string. If the server returns a concrete__typenamethe 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 returnnullon mismatched__typename. For closedunionschema members this is exact; for openinterfaceimplementors it's a small fiction.Test plan
tests/*/Generated/,examples/Generated/) regenerated and round-trip via--ensure-sync.tests/Planner/PayloadShapeBuilderTest.phpexpectations updated for the 8 polymorphic-selection tests.InlineFragments,InlineFragmentTypename,QueryObjectTypename,HooksInUnionVariant,Optimization,ExplicitTypename,Fragmentsgenerated outputs.https://claude.ai/code/session_01WwMs8ZS9JBdXD6ZzwREpzF
Generated by Claude Code