Skip to content
advanced Phase 8 · Schema Design Patterns and Type System Depth

Interfaces and Unions for Polymorphism

Sharing fields across related types with interfaces and grouping unrelated types with unions.

55m
1 problems
Topic Progress 0%

Shared Fields vs Shared Label

Shared Fields vs Shared Label

A catalog search returns three kinds of hit: products, articles, and vendor pages. Two questions decide the abstraction.

Do the types share fields the client should read without asking which type it got? Then an interface. Do they share nothing but the fact that they are all results? Then a union.

interface SearchResult {
  id: ID!
  title: String!
}

type Product implements SearchResult {
  id: ID!
  title: String!
  price: Money!
  sku: String!
}

type Article implements SearchResult {
  id: ID!
  title: String!
  author: Author!
  publishedAt: DateTime!
}

type VendorPage implements SearchResult {
  id: ID!
  title: String!
  domain: String!
}

union SearchHit = Product | Article | VendorPage
Interface Union
Contract shared fields every member declares shared label only
Reading shared fields directly, no fragment needed not possible
Reading type-specific fields ... on Product { price } a fragment for every member
New member joins interface gains the type's fields for free clients with exhaustive fragments miss it
Server resolution __resolveType or __typename same

The difference shows up in the query:

# interface: shared fields need no fragment
query Featured {
  featured { id title }
}

# union: every selection needs a type condition
query Search($q: String!) {
  search(q: $q) {
    results {
      __typename
      ... on Product   { id title price }
      ... on Article   { id title author { name } }
      ... on VendorPage { id title domain }
    }
  }
}

__typename in the union query is not decoration — it is how the client knows which branch arrived before it decides how to render. Choosing wrong is cheap in one direction: a union later grows an interface when shared fields appear, but moving from an interface to a union forces every client to rewrite selections, because fields that used to be readable now need fragments.

Implementing, Querying, and Resolving

Implementing, Querying, and Resolving

What the schema enforces

interface Node { id: ID! }
interface Timed { createdAt: DateTime! }

type Product implements Node & Timed {
  id: ID!
  createdAt: DateTime!
  price: Money!     # extra fields are allowed and expected
}

type Article implements Node & Timed {
  id: ID!
  createdAt: DateTime!
  body: String!
}

Two rules have teeth:

  1. Every implementing type declares every interface field with a compatible type. Leave createdAt off Article and the schema fails to build: "Type Article must declare field createdAt." Extra fields beyond the interface are where type-specific data lives, so the contract is a floor, not a ceiling.
  2. A type may implement multiple interfaces, provided no two of them declare the same field with conflicting types. implements Node & Timed composes behavior across domains; conflicting definitions fail validation instead of silently shadowing each other.

Picking the runtime type

An abstract field arrives at the resolver as a plain object with no GraphQL type attached, so something must say which member it is:

const resolvers = {
  SearchResult: {
    __resolveType: (obj) => obj.__typename, // stamped where the data loads
  },
  SearchHit: {
    __resolveType: (obj) => {
      if (obj.sku) return 'Product';
      if (obj.authorId) return 'Article';
      return 'VendorPage';
    },
  },
};

Stamping __typename during load beats property-based inference: one missed branch in the inference becomes a runtime error that surfaces only for the rare object shape — "Abstract type SearchResult must resolve to an Object type at runtime." Returning null or a name that is not a member fails execution for that response, so a test that walks every member of possibleTypes and asserts resolution pays for itself immediately.

Abstract types also interact with nullability. If a member type omits a non-null interface field at runtime — the database returned null for title — the error propagates according to the same null-propagation rules a query follows, which is worth checking when interface fields are non-null and member data comes from joins.

Evolving Abstract Types Without Breaking Clients

Evolving Abstract Types Without Breaking Clients

Interfaces and unions evolve differently, and clients break differently for each.

Adding a field to an interface. Every implementer must gain it in the same release or the schema will not build — an internal failure you catch in CI, never a client-facing one. Clients see an additive change. The cost is coordinated edits across every implementing type and its resolvers, which is why interfaces should stay small: Node with an id is cheap to implement, a five-field interface taxes every new type.

Adding a member to a union. The schema builds fine, but a client that enumerates members with inline fragments silently drops the new type: the object arrives, no fragment matches, and a hole appears in the UI. The resilient client pattern handles __typename first and treats anything unknown as generic:

switch (hit.__typename) {
  case 'Product':  return renderProduct(hit);
  case 'Article':  return renderArticle(hit);
  default:         return renderGeneric(hit); // survives members added later
}

Removing a member or a shared field breaks clients both ways and needs the runway in deprecation strategy.

The choice is reversible in one direction

Moving from a union to an interface is smooth: introduce the interface, add implements to each member, and existing fragments keep matching because union fragments are still legal on the new shape. Moving from an interface to a union forces every client to rewrite selections, because fields that were readable directly now need type conditions. If the types may converge on a shape, start with the interface; if they are genuinely unrelated results, a union costs clients less to read.

What tooling needs

Apollo and similar clients fetch __schema { types { possibleTypes { name } } } once at startup to know which fragments are legal on which abstract field — that is how a client-side cache normalizes ... on Product into the right normalized entry. Designing with possibleTypes in mind — every union member covered by some fragment, shared fields read through the interface — keeps both the cache and the rendered UI correct as the graph grows.

Practice Problems

0 / 1 solved
Design the Abstract Types for Search Results
Polymorphic Schema Design

Search returns Product (has price and sku), Article (has author and publishedAt), and VendorPage (has domain). All three share id and title, and results must be listable in one round trip. Write the SDL with the right abstraction, the query a client sends, and the resolver that identifies each runtime type.

Example:

Input: A query wants id, title, and price for every result, showing price only when it exists

Output: interface SearchResult { id title } + union SearchHit = Product | Article | VendorPage, queried with shared fields plus ... on Product { price }

Shared fields come from the interface without fragments, while the union keeps the member list honest for fields that do not exist on every type.

Optimal Solution — O(m) to resolve the type across m members with property checks time, O(1) when __typename is stamped at load space

Model shared fields as an interface and read type-specific fields through inline fragments, stamp __typename where the data loads so resolveType is a lookup, and make clients default on unknown members.

interface SearchResult {
  id: ID!
  title: String!
}

type Product implements SearchResult {
  id: ID!
  title: String!
  price: Money!
  sku: String!
}

type Article implements SearchResult {
  id: ID!
  title: String!
  author: Author!
  publishedAt: DateTime!
}

type VendorPage implements SearchResult {
  id: ID!
  title: String!
  domain: String!
}

union SearchHit = Product | Article | VendorPage

type Query {
  search(q: String!): [SearchHit!]!
}

# client
query Search($q: String!) {
  search(q: $q) {
    __typename
    ... on Product    { id title price }
    ... on Article    { id title author { name } }
    ... on VendorPage { id title domain }
  }
}

# server
const resolvers = {
  SearchHit: { __resolveType: (obj) => obj.__typename },
};

Edge Cases:

  • An implementer missing a non-null interface field at runtime
  • resolveType returning null for an unrecognized shape
  • A new union member arriving at a client with no matching fragment

Quiz

1. Search results include Product and Article, and both have id and title. What do clients need to select those shared fields?

Question 1 options

2. What must be true of an object type that declares implements Node?

Question 2 options

3. A union gains a new member type and a client with exhaustive inline fragments renders a blank card. What is the root cause?

Question 3 options

Flashcards

Question

Interface vs union in one line

Answer

An interface is a shared field contract that implementers must satisfy; a union is a shared label over unrelated types that requires type conditions to read.

Question

Interface implementation rules

Answer

Every implementer declares all interface fields with compatible types (schema fails otherwise), may add its own fields, and a type may implement several interfaces if no field conflicts.

Question

How an abstract value gets its type

Answer

Return a member name from __resolveType, or stamp __typename when loading data. Returning null or a non-member errors execution.

Question

Adding a member: who breaks

Answer

Schema stays valid; clients with exhaustive fragments silently miss the new type. Handle __typename with a default branch.

Question

Direction the abstraction choice is reversible in

Answer

Union to interface is smooth (fragments keep matching); interface to union forces clients to rewrite selections because shared fields now need fragments.

Revision Notes

Key Takeaways

  • 1. Interfaces share fields; unions share only a label — pick by whether clients can read without fragments
  • 2. Implementers get a floor of required fields and a ceiling of none; conflicting multi-interface fields fail validation
  • 3. Runtime type comes from __resolveType or a stamped __typename, and a test should cover every possible type
  • 4. Adding union members is additive for the schema and silent for careless clients — default branches are the defense

Interview Tips

  • • Answer with the decision rule first (shared fields or not), then show the query difference between the two
  • • Be ready to name the exact schema-build error when an implementer misses an interface field
  • • Discuss client evolution: what a new union member does to a client with exhaustive fragments, and how you write the client to survive it

Cheat Sheet

Interfaces and Unions

Choose

  • Types share fields -> interface (clients read them without fragments)
  • Types share only a label -> union (every selection needs ... on Type)

SDL rules

interface Node { id: ID! }
type Product implements Node & Timed {
  id: ID!          # required: matches interface exactly
  createdAt: DateTime!
  price: Money!    # allowed: concrete extras
}
union SearchHit = Product | Article | VendorPage

Execution

  • Shared fields on an interface need no fragment; union members always do.
  • Resolve runtime type via __resolveType (or stamp __typename at load).
  • Always include __typename in union selections so the client can branch.

Evolution

  • Add interface field: all implementers in the same release (CI catches misses).
  • Add union member: schema fine, clients need a default branch.
  • Union -> interface is safe; interface -> union breaks client selections.

Continue Learning