skip to content

How does a GraphQL client tell which member of a union result type it received?

level: middleimportance: should knowfreq 48%

answer

  1. The members share nothing in common
  2. A meta-field carries the answer
  3. Only fragments and meta-fields may be selected
  4. It names the concrete type, not the union
  5. Always write the default arm

basics

~20 s

By selecting the __typename meta-field, which returns the name of the concrete object type, and by writing one inline fragment per member for that member's own fields. A default branch handles members the document does not cover.

solid answer

~50 s

A selection set on a union-typed field may contain only fragments and meta-fields — you cannot select a plain field on a union, even one every member happens to declare. So the client writes `__typename` plus an inline fragment per member: `... on Listing { askingPrice }`, `... on ListingWithdrawn { reason }`. `__typename` returns `String!` holding the name of the **concrete object type** of that value, and the response object contains only the keys contributed by fragments whose type condition matched. The client then switches on that string. Two consequences matter: a member no fragment covers yields an object with just the selected meta-fields, and a member added to the union later is exactly that case — so a default branch is mandatory, not defensive style. An interface-based result differs: fields the interface declares are selectable directly, without a fragment.

code

graphql · 13 lines
graphql
query ListingDetail($id: ID!) {
  listing(id: $id) {
    __typename
    ... on Listing {
      address
      askingPrice
    }
    ... on ListingWithdrawn {
      withdrawnAt
      reason
    }
  }
}

go deeper

for a junior

Remember the two ingredients: select __typename, then write one inline fragment per member you care about. Knowing that the value names the concrete type is enough to read most result-union documents.

for a middle

Be ready to state the validation rule — only fragments and meta-fields inside a union selection set — and to explain how the executor assembles the response from matching fragments only.

for a senior

Show that you have been bitten: a member added server-side leaves old clients with a thin object and a successful response, so the default arm and the generated exhaustiveness check are what stop a silent blank screen.

for a principal

Own the evolution contract. Decide whether new failure kinds arrive as union members or as implementations of a shared interface, and make that choice once for the graph so every client writes its fallback the same way.

## Why a union needs a discriminator at all A union type says a field may return one of several **object types that share nothing**. There is no common field, no common interface, no structural overlap the client can lean on. So the response for a union-typed field is an object whose shape depends entirely on which member came back — and the client needs some way to know which one that was before it can render anything. GraphQL's answer is the `__typename` **meta-field**. It is available in any object, interface or union selection set, it takes no arguments, its type is `String!`, and its value is the **name of the concrete object type** of the value at that position — never the name of the union or interface. (The specification carves out one exception: `__typename` may not be included at the root of a subscription operation.) ## What you may select on a union This is the mechanic interviewers actually probe. A selection set on a union-typed field may contain **only fragments and meta-fields**. Even if every member of `ListingResult` happens to declare a `message` field, `listing { message }` is invalid — validation rejects it, because a union declares no fields of its own for a field selection to resolve against. So the shape is fixed: ```graphql query ListingDetail($id: ID!) { listing(id: $id) { __typename ... on Listing { id address askingPrice } ... on ListingWithdrawn { withdrawnAt reason } } } ``` The `... on X { ... }` form is an **inline fragment**; a named fragment with the same type condition, spread here, does the identical job and is how teams share a branch between documents. ## How the response is assembled During execution the server collects the fields to resolve for this position by walking the selection set and keeping only fragments whose **type condition matches the concrete type** of the value. Everything else contributes nothing at all — it is not an error, it simply does not appear. That gives the rule the whole answer turns on: **the response object contains exactly the keys contributed by matching fragments, plus any meta-fields selected directly.** If the value is a `ListingWithdrawn` and the document has no fragment on `ListingWithdrawn`, the object is `{"__typename": "ListingWithdrawn"}` — and if `__typename` was not selected either, it is `{}`. ```json { "data": { "listing": { "__typename": "ListingWithdrawn", "withdrawnAt": "2026-08-19T09:41:07Z", "reason": "SOLD_OFF_MARKET" } } } ``` ## The client side A client switches on the string and must have a default arm: ``` result = response.data.listing switch result.__typename: case "Listing": renderListingCard(result) case "ListingWithdrawn": renderWithdrawnNotice(result.reason) case "ListingNotFound": renderMissing(result.requestedId) default: renderGenericProblem() ``` The default is not paranoia. Adding a member to a union is a change the server can make at any time without breaking a single existing document — every old query stays valid, and every old client starts receiving objects it has no branch for. Without a default arm those requests render as blank space, and because the response is a completely successful one, nothing in the errors list and nothing in the client's generic failure handling ever fires. Typed client generators help here by turning the union into a closed set the client's own compiler checks for exhaustiveness, but only against the schema snapshot the code was generated from. ## Interfaces discriminate differently If the result is an **interface** rather than a union, the picture changes usefully. Fields the interface itself declares are selectable directly, no fragment required, so a client can write `listing { __typename ... }` and select a shared `message` field that renders correctly for every implementing type — including implementations added after the document was written. Inline fragments are then needed only for the branches you treat specially. That is the main reason a team choosing a carrier for failure types picks an interface: it converts "a member I have never heard of" from a blank screen into a generic but correct rendering. ## Common mistakes Selecting a field directly on a union and being surprised by a validation error. Expecting `__typename` to name the union. Assuming an unmatched member produces an error rather than a thin object. And treating exhaustiveness as guaranteed forever, when the server can add a member the day after your client shipped.

  • Can you select a field directly on a union type if every member declares it?
    No. A selection set on a union may contain only fragments and meta-fields, so `listing { message }` fails validation however uniform the members are. A union declares no fields of its own. If you want a directly selectable shared field, the carrier has to be an interface, which does declare fields that every implementation must provide.
  • What happens to an existing client when a new member is added to a union?
    Its documents stay valid and its requests keep succeeding — but no inline fragment matches the new member, so the object arrives with only the meta-fields it selected. The client falls into its default arm, or renders nothing at all if it has none. Adding a union member is safe for the server and quietly consequential for old clients, which is why the default arm is mandatory.
  • Is __typename ever unavailable?
    It is available in any object, interface or union selection set, with one carve-out in the specification: it may not be included at the root of a subscription operation. Everywhere else it is a meta-field the server answers itself, and it is not affected by whether introspection of the schema is disabled — that switch concerns `__schema` and `__type`.
  • Does selecting __typename cost anything at execution time?
    Essentially nothing. The executor already knows the concrete type of the value it is completing, so answering the meta-field is a lookup, not a resolver call or a backend query. Clients and normalized caches often select it on every object for exactly that reason.

saying these in an interview costs you the question

  • Selects a shared field directly on a union type
  • Says __typename returns the union's name
  • Assumes an unmatched member produces an error
  • Treats a client's branch list as permanently exhaustive
  • Writes no default arm for unknown members
  • Confuses __typename with the __schema introspection field

context