Unions
Union types are mutually exclusive object types — SearchResult = User | Post | Comment. Unlike interfaces they share NO fields; clients always fragment to read anything. Reach for them when a field can return one of several unrelated shapes.
Declare, query, resolveType, errors-as-data
EXAMPLE
// 1) Declare a union
union SearchResult = User | Post | Comment
union FeedItem = StatusUpdate | SharedLink | Poll
type User { id: ID! name: String! email: String! }
type Post { id: ID! title: String! body: String! }
type Comment { id: ID! body: String! post: Post! }
type Query {
search(q: String!): [SearchResult!]!
feed: [FeedItem!]!
}
// 2) Query — clients fragment on each variant
query Search($q: String!) {
search(q: $q) {
__typename
... on User { id name email }
... on Post { id title }
... on Comment { id body post { title } }
}
}
// __typename is recommended — clients use it to discriminate at runtime.
// 3) Resolver — server must say which type each value is
const resolvers = {
SearchResult: {
__resolveType(obj) {
if (obj.email) return 'User';
if (obj.title) return 'Post';
if (obj.body && obj.postId) return 'Comment';
return null;
},
},
Query: {
search: async (_p, { q }, ctx) => {
const [users, posts, comments] = await Promise.all([
ctx.db.user.findMany({ where: { name: { contains: q } }, take: 5 }),
ctx.db.post.findMany({ where: { title: { contains: q } }, take: 5 }),
ctx.db.comment.findMany({ where: { body: { contains: q } }, take: 5 }),
]);
return [...users, ...posts, ...comments];
},
},
};
// 4) Union vs Interface — quick rule
// Interface — types share FIELDS (Node { id: ID! }); clients can read shared fields without fragments
// Union — no shared fields; ALL reads need fragments; truly different shapes
// 5) Errors as data — the killer use case for unions
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
union CreatePostPayload = Post | ValidationError | RateLimitError | AuthError
type ValidationError { fields: [FieldError!]! }
type FieldError { path: [String!]! message: String! }
type RateLimitError { retryAfterSec: Int! message: String! }
type AuthError { reason: AuthReason! message: String! }
enum AuthReason { UNAUTHENTICATED FORBIDDEN MFA_REQUIRED }
// Resolver returns the variant matching what happened
const createPost = async (_p, { input }, ctx) => {
if (!ctx.user) return { reason: 'UNAUTHENTICATED', message: 'Sign in required', __typename: 'AuthError' };
const valid = validate(input);
if (!valid.ok) return { fields: valid.fields, __typename: 'ValidationError' };
if (await ctx.limiter.tooMany(ctx.user.id, 'createPost')) {
return { retryAfterSec: 60, message: 'Too many', __typename: 'RateLimitError' };
}
const post = await ctx.db.post.create({ data: { ...input, authorId: ctx.user.id } });
return { ...post, __typename: 'Post' };
};
// 6) Client side — exhaustive handling
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
__typename
... on Post { id title }
... on ValidationError { fields { path message } }
... on RateLimitError { retryAfterSec message }
... on AuthError { reason message }
}
}
// In TypeScript via codegen — discriminated union; switch is exhaustive
switch (result.__typename) {
case 'Post': return showSuccess(result);
case 'ValidationError': return showFieldErrors(result.fields);
case 'RateLimitError': return retryAfter(result.retryAfterSec);
case 'AuthError': return redirectLogin(result.reason);
}
// 7) Why this beats GraphQL's 'errors' array
// • Server-modelled, exhaustive at compile time
// • Doesn't muddle expected outcomes (validation failure) with system errors (database down)
// • Easier to handle in typed clients
// 8) Unions in lists vs single fields
type Query {
inbox: [Notification!]! // each item: Like | Reply | Mention | System
}
union Notification = Like | Reply | Mention | System
// 9) Federation — unions in subgraph composition
# Apollo Federation supports unions; each member can come from any subgraph.
# Be careful: a subgraph that owns a union member must include __resolveReference.
// 10) Common bugs
// • Forgot __resolveType → 'Cannot determine type of ...'
// • Two member types with identical fields → __resolveType heuristic ambiguous; add a discriminator field
// • Client misses a fragment → __typename + UI fallback prevents blank screens
// • Adding a union member is a BACKWARDS-COMPATIBLE schema change — old clients ignore unknown __typename
// • Removing a union member is BREAKING
// • Confusing union with interface — pick based on whether the types share fields
// • Trying to write input unions (union of input types) — NOT supported by spec; use multiple fields or oneOf input (proposed 2023)
// • Forgetting to handle all members on the client → silent gaps when new member ships
Why it matters
Use unions when a field can return mutually exclusive types with no shared fields — perfect for search results and (powerfully) for errors-as-data: a mutation returns Post | ValidationError | RateLimitError. The client exhaustively handles each variant; the server stops shoehorning expected outcomes into the errors array.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Discussion
Loading…