skip to content

In Apollo Client 4, a search query spreads a fragment on the SearchResult union of Product and Brand, and every hit renders blank; what is missing?

level: seniorimportance: should knowfreq 28%

answer

  1. the cache must know the schema
  2. abstract type in the condition
  3. supertype to member-type map
  4. equal type names still match
  5. regenerate when the schema grows

basics

~20 s

InMemoryCache needs possibleTypes, for example { SearchResult: ["Product", "Brand"] }. Without it, a fragment whose type condition names a union or interface matches no object, so its fields are never written or read and each hit keeps only __typename.

solid answer

~40 s

`InMemoryCache` decides whether a fragment applies to an object with its `fragmentMatches` check. A fragment with no type condition always applies, and one whose condition equals the object's `__typename`, such as `... on Product`, matches without any configuration. When the condition names an abstract type, like `fragment SearchHitFields on SearchResult`, the cache needs to know that `Product` and `Brand` belong to `SearchResult`, and it learns that only from the `possibleTypes` option. Without it the fragment matches nothing: its fields are neither written to the cache nor read back, no error is raised, and each hit arrives as `{ __typename: "Product" }`. Pass `new InMemoryCache({ possibleTypes })` with a map generated from the schema, and regenerate it whenever a union or interface gains a member.

code

graphql · 18 lines
graphql
query Search($term: String!) {
  search(term: $term) {
    ...SearchHitFields
  }
}

fragment SearchHitFields on SearchResult {
  ... on Product {
    id
    name
    price
  }
  ... on Brand {
    id
    name
    logoUrl
  }
}

go deeper

for a junior

Recall that InMemoryCache needs possibleTypes before fragments on unions or interfaces can match, and that it maps each abstract type to its member types.

for a middle

Explain the matching order: no condition, equal type names, then the possibleTypes map, and why inline fragments on concrete types work without it.

for a senior

Recognise blank union results with no error as a fragment-matching failure, fix it with a generated map, and guard against the map going stale.

for a principal

Treat possibleTypes as schema-derived build output with an owner and a drift check, since every schema change to a union or interface can silently break clients.

## How InMemoryCache decides that a fragment applies Every time Apollo Client writes a result into `InMemoryCache` or reads one back, it walks the selection set. For each inline fragment or fragment spread it asks one question: does this fragment apply to this object? The check is the cache's `fragmentMatches` method, and it runs in this order: 1. **No type condition** (`... @include(if: $full) { name }`): the fragment always applies. 2. **The object has no `__typename`**: a fragment with a type condition cannot apply. Apollo Client 4 always adds `__typename` to nested selections, so this is rare. 3. **The type condition equals `__typename`** (`... on Product` for a `Product`): it applies, with no configuration needed. 4. **The type condition names a supertype listed in `possibleTypes`**: the cache walks the map, including supertypes of supertypes, and applies the fragment if the object's type is among the members. 5. **Anything else**: the fragment does not apply. Step 4 is the only place the cache learns about unions and interfaces. `InMemoryCache` does not download the schema, so the relationships must be given to it. ## Why the search hits are blank The search page runs `search(term: $term) { ...SearchHitFields }`, where `SearchHitFields` is defined `on SearchResult` and contains `... on Product { id name price }` and `... on Brand { id name logoUrl }`. The server answers correctly. Then the cache processes the response: - Each hit has `__typename: "Product"` or `"Brand"`, but the spread's type condition is `SearchResult`. - Without `possibleTypes`, step 4 has nothing to consult, so the spread does not match and none of its fields are written. - When the query is read back from the cache, the same check skips the same fragment, so the result is consistent and nothing is reported missing. - Each hit reaches the component as `{ __typename: "Product" }`, and the result card renders empty. No error is raised. | Selection on a `Product` hit | Type condition | Matches without `possibleTypes`? | |---|---|---| | `... on Product { name }` | `Product` | yes, names are equal | | `...SearchHitFields` | `SearchResult` (union) | no | | `... on Sellable { price }` | `Sellable` (interface `Product` implements) | no | | `... @include(if: $full) { name }` | none | yes | This is why the bug surprises people: a query that uses inline fragments on the concrete types directly under `search` works, and only a fragment written against the abstract type fails. ## The fix Give the cache the supertype-to-member map: ```ts const cache = new InMemoryCache({ possibleTypes: { SearchResult: ["Product", "Brand"], Sellable: ["Product"], }, }); ``` Each key is a union or interface name, and each value lists the object types that belong to or implement it. Data masking uses the same `fragmentMatches` check when it decides which inline-fragment fields a parent may see, so a wrong map affects masked results too. ## Keeping possibleTypes true to the schema A hand-written map is fine for two unions. For a real schema it should be **generated**: - An introspection query over `__schema { types { name possibleTypes { name } } }` produces the map; Apollo's documentation ships a small script for it. - A code generator's fragment-matcher output writes the same JSON during the build. - Regenerate on every schema change. When the schema later adds `Collection` to `SearchResult` and the map is stale, collection hits come back blank in exactly the same silent way, even once the fragment has a `... on Collection` branch. - A build step that fails when the generated map differs from the committed one keeps the map from drifting. ## What changed in Apollo Client 4 - Apollo Client 3 accepted a `fragmentMatcher` option on the `ApolloClient` constructor for matching fragments in `@client` local-state selections. Version 4 **removed** it, along with `setLocalStateFragmentMatcher`; fragment matching now belongs to the cache's `fragmentMatches`, which `InMemoryCache` implements from `possibleTypes`. - Custom cache implementations must implement `fragmentMatches`, because local state relies on it. - `possibleTypes` also drives type-policy inheritance in `InMemoryCache`, so a stale map can affect cache configuration inherited from an interface, not only fragment matching. - `possibleTypes` itself works as it did in version 3; the migration guide's advice is to check that the map is current with the schema.

  • Months later the schema adds Collection to SearchResult, but possibleTypes is not regenerated. What does the search page show in Apollo Client?
    Product and brand hits still render, because the map lists them. Even after `SearchHitFields` gains a `... on Collection` branch, collection hits fail the `fragmentMatches` check for the spread, so their fields are neither stored nor returned and they render blank, again with no error. The fix is regenerating the map from the schema, ideally in the same build step that consumes schema changes, with a check that fails when the committed map is stale.
  • Why does the same search work in Apollo Client without possibleTypes when the query writes ... on Product and ... on Brand directly under search?
    `fragmentMatches` returns true straight away when an inline fragment's type condition equals the object's `__typename`. `... on Product` on a `Product` hit is that case, so the map is never consulted. `possibleTypes` is needed only when the type condition names a union or interface, as `SearchHitFields on SearchResult` does.
  • An Apollo Client 3 setup passes fragmentMatcher to the ApolloClient constructor. What happens to it in Apollo Client 4?
    The option is gone: 4.0 removed `fragmentMatcher` and `setLocalStateFragmentMatcher`. Fragment matching now lives in the cache's `fragmentMatches` method, which `InMemoryCache` implements from `possibleTypes`. Delete the option and make sure `possibleTypes` covers the schema's unions and interfaces; a custom cache has to implement `fragmentMatches` itself.

saying these in an interview costs you the question

  • Apollo needs possibleTypes even for ... on Product selected on a Product.
  • Without possibleTypes Apollo throws an error naming the fragment that failed.
  • InMemoryCache downloads the schema's unions and interfaces at startup.
  • In Apollo Client 4, pass fragmentMatcher to ApolloClient to match union members.
  • possibleTypes maps each object type to the fields it can return.
  • A possibleTypes map written once never needs updating.