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:
- Every implementing type declares every interface field with a compatible type. Leave
createdAtoffArticleand 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. - A type may implement multiple interfaces, provided no two of them declare the same field with conflicting types.
implements Node & Timedcomposes 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
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?
2. What must be true of an object type that declares implements Node?
3. A union gains a new member type and a client with exhaustive inline fragments renders a blank card. What is the root cause?
Flashcards
Question
Interface vs union in one line
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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
Click to reveal answer
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.