GraphQL Polymorphism: Unions vs Interfaces with Real‑World Patterns
Learn how to model polymorphism in GraphQL with unions and interfaces, when to use each, querying patterns, resolvers, and best practices.
Image used for representation purposes only.
Why polymorphism matters in GraphQL
APIs rarely return a single shape of data. A search endpoint might return users, organizations, ads, and articles together. A payment history could mix charges, refunds, and disputes. GraphQL models this variety using polymorphism—primarily through unions and interfaces—so clients can request exactly what they need without multiple round trips or leaky abstractions.
This article explains how GraphQL unions and interfaces work, when to use each, how to query them effectively, how to implement robust type resolution on the server, and the pitfalls to avoid in production.
Abstract types in GraphQL
GraphQL supports two abstract (polymorphic) types:
- Interfaces: Declare a set of shared fields. Object types implement the interface and must provide at least those fields.
- Unions: A value can be one of several object types that do not need to share fields.
Both are “abstract” because the concrete value at runtime is always an object type. Clients learn the concrete type using fragments and the special __typename meta field.
Interfaces vs unions at a glance
Use an interface when:
- You have a meaningful common contract (e.g.,
id,title,createdAt). - Clients should be able to select common fields without type-specific fragments.
- You expect to paginate or relate these nodes generically (e.g., a
Node { id: ID! }interface).
Use a union when:
- The members have little or no overlap in fields.
- You want to return heterogeneous results (e.g., search) without inventing artificial common fields.
- Validation should not force a shared contract.
Rule of thumb: prefer interfaces for consistency and evolvability; choose unions for truly heterogeneous shapes.
Modeling polymorphism in SDL
Interface example:
interface Media {
id: ID!
title: String!
url: String!
}
type Image implements Media {
id: ID!
title: String!
url: String!
width: Int!
height: Int!
}
type Video implements Media {
id: ID!
title: String!
url: String!
durationSeconds: Int!
}
type Query {
featured: Media!
gallery: [Media!]!
}
Union example:
union SearchResult = User | Organization | Article | Ad
type User { id: ID!, username: String!, avatarUrl: String }
type Organization { id: ID!, name: String!, logoUrl: String }
type Article { id: ID!, headline: String!, teaser: String }
type Ad { id: ID!, imageUrl: String!, ctaText: String! }
type Query {
search(q: String!): [SearchResult!]!
}
Notes:
- You cannot define fields directly on a union.
- Interface fields are guaranteed and can be selected without fragments.
Querying polymorphic fields
Fragments let clients select fields for specific concrete types while keeping one coherent query.
Fragments on an interface (shared fields + type-specific additions):
query Gallery {
gallery {
__typename
id
title
... on Image { width height }
... on Video { durationSeconds }
}
}
Fragments on a union (type-specific fields only):
query Search {
search(q: "graphql") {
__typename
... on User { id username avatarUrl }
... on Organization { id name logoUrl }
... on Article { id headline teaser }
... on Ad { id imageUrl ctaText }
}
}
Inline fragments are helpful when you need conditional selections inside deeper objects:
node(id: "...") {
__typename
... on Media { title url }
}
Tip: always request __typename with abstract types. It helps client caches, debugging, and analytics.
Resolving types on the server
GraphQL needs to map a returned value to one of the possible object types. You can implement this by:
resolveTypeon an interface or unionisTypeOfon each object type (less common at scale)
Example in JavaScript/TypeScript (GraphQL.js or Apollo Server-style):
const SearchResult = {
__resolveType(value) {
if (value.kind === 'USER') return 'User';
if (value.kind === 'ORG') return 'Organization';
if (value.kind === 'ARTICLE') return 'Article';
if (value.kind === 'AD') return 'Ad';
return null; // triggers a field error if unresolved
}
};
const Media = {
__resolveType(value) {
return value.durationSeconds != null ? 'Video' : 'Image';
}
};
Guidelines for robust resolution:
- Use an explicit discriminator field from your data model (e.g.,
kind,type), not ad‑hoc heuristics. - Ensure every possible record maps to a valid member type; return
nullonly for genuinely unknown cases (which will error that field path). - Keep resolution fast—do not perform extra I/O inside
resolveType. Compute the discriminator with already-fetched data.
Nullability and lists with abstract types
GraphQL’s nullability interacts with polymorphism the same way it does with concrete types:
- A field of type
[SearchResult!]!means the list itself and every item must be non-null. If any item errors or resolves tonull, the whole list becomesnull, and errors can bubble. - A safer default is
[SearchResult!]so items are non-null but the list may benullon error. - Use non-null carefully on abstract types. One bad item should not take down an entire page of results unless you truly require all items.
Pagination and the Node interface
Many schemas standardize on a Node interface with a globally unique id, enabling generic pagination and caching patterns (popularized by Relay):
interface Node { id: ID! }
type User implements Node { id: ID!, username: String! }
type Article implements Node { id: ID!, headline: String! }
type Query {
node(id: ID!): Node
searchConnection(q: String!, first: Int, after: String): SearchResultConnection!
}
union SearchResult = User | Article | Ad
type SearchResultEdge { cursor: String!, node: SearchResult! }
type SearchResultConnection { edges: [SearchResultEdge!]!, pageInfo: PageInfo! }
type PageInfo { hasNextPage: Boolean!, endCursor: String }
This pattern lets clients fetch mixed results while paginating generically over edges/node.
Polymorphism across service boundaries
In distributed schemas (federation or stitching), abstract types often span subgraphs. Keep these rules in mind:
- Align type names and discriminators across services.
- Avoid circular references between abstract types across subgraphs.
- Document which subgraph owns the
__resolveTypelogic (or its equivalent). Consistency prevents subtle routing or composition errors.
Client caching and fragments
Client caches (Apollo, Relay, urql) normalize by __typename + id when possible. Best practices:
- Always select
__typenameon abstract selections. - For interface members, ensure each implementing type exposes the same identifier field shape (e.g.,
id: ID!). - Co-locate fragments with UI components to keep type conditions obvious and maintainable.
Example normalized response shape:
{
"data": {
"search": [
{ "__typename": "User", "id": "u1", "username": "alice" },
{ "__typename": "Article", "id": "a9", "headline": "GraphQL Tips" }
]
}
}
Performance considerations
- Batch by
__typename. If your union returns different backends per type, partition IDs by typename and use type-specific DataLoaders. - Avoid N+1 inside fragments. Fragments do not change resolver execution; you still need batching at each field that does I/O.
- Consider over-fetch vs under-fetch trade-offs. Interfaces can reduce fragment sprawl by allowing shared fields to be selected once.
- Cache
resolveTyperesults if they are expensive to compute repeatedly in a large list.
Example batching by typename:
async function hydrateSearch(items) {
const groups = items.reduce((acc, item) => {
(acc[item.__typename] ||= []).push(item.id);
return acc;
}, {});
const [users, articles, ads] = await Promise.all([
loadUsers(groups.User || []),
loadArticles(groups.Article || []),
loadAds(groups.Ad || []),
]);
// merge back to original order...
}
Evolving schemas safely
- Adding a new object type that implements an existing interface is usually safe; existing selections of interface fields continue to work.
- Adding a new member to a union or as a possible type of an interface is often considered a “dangerous” change: most clients tolerate it, but clients that assume an exhaustive type switch may break. Communicate and version accordingly.
- Removing a member or removing a field from an interface is a breaking change.
- Prefer additive evolution: add fields (with sensible nullability), keep older ones until fully deprecated and removed.
Security and validation
- Validate
resolveTypestrictly; do not allow untrusted inputs to choose arbitrary typenames. - Use authorization at the field level. Different union members may have different visibility rules; enforce them inside resolvers.
- Consider masking sensitive fields via separate types rather than conditional nulls inside one type. Clear types are easier to secure.
End-to-end example: a practical search
Schema:
interface Node { id: ID! }
interface Titled { title: String! }
type Product implements Node & Titled {
id: ID!
title: String!
price: Int!
}
type Collection implements Node & Titled {
id: ID!
title: String!
handle: String!
}
type Editorial implements Node & Titled {
id: ID!
title: String!
readingTimeMin: Int!
}
type Ad implements Node { id: ID!, imageUrl: String!, ctaText: String! }
union SearchResult = Product | Collection | Editorial | Ad
type Query {
search(q: String!): [SearchResult!]!
}
Query:
query SiteSearch($q: String!) {
search(q: $q) {
__typename
... on Titled { id title }
... on Product { price }
... on Collection { handle }
... on Editorial { readingTimeMin }
... on Ad { imageUrl ctaText }
}
}
Server type resolution (sketch):
const SearchResult = {
__resolveType(obj) {
switch (obj.kind) {
case 'PRODUCT': return 'Product';
case 'COLLECTION': return 'Collection';
case 'EDITORIAL': return 'Editorial';
case 'AD': return 'Ad';
default: return null;
}
}
};
Response (abridged):
{
"data": {
"search": [
{ "__typename": "Product", "id": "p1", "title": "Mug", "price": 1299 },
{ "__typename": "Editorial", "id": "e4", "title": "Gift Guide", "readingTimeMin": 6 },
{ "__typename": "Ad", "id": "ad9", "imageUrl": "https://.../ad.jpg", "ctaText": "Shop now" }
]
}
}
A practical decision checklist
- Do types share a real contract? Use an interface; otherwise, a union.
- Will clients paginate or reference these nodes generically? Provide a
Node { id }interface. - Do you need shared selections without fragments? Interface.
- Are you mixing truly unrelated shapes? Union.
- Is server resolution trivial via a discriminator? Proceed; otherwise, refactor data or types.
- Are you ready for new members in the future? Communicate changes and avoid exhaustive client switches.
Common pitfalls and how to avoid them
- Expecting to select fields on a union without fragments. Use fragments or switch to an interface with shared fields.
- Forgetting
__typename. Always request it with abstract selections. - Expensive
resolveTypewith extra DB calls. Use discriminators included in the parent fetch. - Overusing unions for “maybe” fields. Sometimes a simple object type with optional fields is clearer.
- Attempting input unions. GraphQL does not support input unions in the base spec; use tagged input objects (e.g.,
PaymentInput { card?: CardInput, paypal?: PaypalInput }with validation that exactly one is present) or separate mutation fields.
Conclusion
Polymorphism is a core strength of GraphQL. Interfaces give you a durable contract and simpler queries; unions embrace heterogeneity without forcing artificial overlap. Choose deliberately, implement fast and safe type resolution, and design with evolution and caching in mind. Your clients will thank you with smaller queries, clearer code, and fewer round trips.
Related Posts
Implementing Custom GraphQL Directives: Patterns, Code, and Pitfalls
A practical guide to designing and implementing custom GraphQL directives with examples in TypeScript and Java.
GraphQL Federation for Microservices: A Practical Architecture Guide
A practical guide to GraphQL federation in microservices—concepts, schema design, gateway patterns, performance, security, and migration strategies.
GraphQL Code Generation: A Practical Client Tutorial with TypeScript and React
A step-by-step tutorial to generate typed GraphQL client code and React hooks with TypeScript, Apollo, or urql—plus scalars, fragments, and CI.