iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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.

Example

Example
union SearchResult = User | Post | Comment
Try it Yourself »

Discussion

Loading…