Modeling Union Types and Interfaces in GraphQL Schemas
AI generated
{ }
type
GraphQL · Schema Design · Type System · PHP
Modeling Union Types and Interfaces in GraphQL Schemas
Type heterogeneous results safely instead of forcing them into one type

A search feature that returns products, categories, and pages in one list outgrows any single object type. Union types and interfaces give GraphQL the tools to model such heterogeneous results without compromising type safety or introspection, with clear rules for which tool fits when.

19 min read Union Types · Interfaces · resolveType · Inline Fragments webonyx/graphql-php · PHP 8.4

1. Why a single type isn't enough for heterogeneous results

A GraphQL field always has exactly one declared return type. As long as that type is a single object like Product or a list thereof, everything works smoothly. But once a field fundamentally needs to return different kinds of objects, for example a search that scans products, categories, and CMS pages at the same time, a single object type is no longer enough. Without union types and interfaces, the only option left would be a forced common type with many optional fields, most of which are null for any given result.

This approach with an artificial catch-all type obscures the actual structure of the data and forces clients to guess what kind of result they're looking at based on heuristics. GraphQL's type system offers two clean alternatives instead: interfaces for types that share common fields, and union types for types that have nothing in common content-wise. Both solve the problem of heterogeneous return values, but with different guarantees for clients.

2. Interfaces: shared fields for related types

An interface in GraphQL defines a set of fields that every implementing type is guaranteed to provide. This is GraphQL's equivalent of interfaces in object-oriented languages: a client querying a field of the interface type can query the fields declared on the interface directly without fragments, because every possible concrete type is guaranteed to have them. Typical candidates for an interface are related entities with overlapping structure, such as Product, Category, and CmsPage, which all have id, url, and metaTitle.


interface Searchable {
  id: ID!
  url: String!
  metaTitle: String
}

type Product implements Searchable {
  id: ID!
  url: String!
  metaTitle: String
  sku: String!
  price: Float!
}

type Category implements Searchable {
  id: ID!
  url: String!
  metaTitle: String
  productCount: Int!
}

type CmsPage implements Searchable {
  id: ID!
  url: String!
  metaTitle: String
  content: String!
}

The decisive advantage of an interface over a union type: a client can query id, url, and metaTitle directly on the interface field, without writing a separate inline fragment for every concrete type. Only for type-specific fields like sku or content are fragments still needed. That makes interfaces the right choice whenever multiple types genuinely share a common field set, not just coincidentally similar names.

3. Union types: completely different types in one field

A union type, on the other hand, defines no shared fields at all. It merely describes which object types are allowed at that point in the schema, without enforcing or expecting any field overlap. This is the right choice when the involved types have nothing in common content-wise, for example a search result that can be either a Product, a BlogPost, or a SupportTicket, three entities without any meaningfully shared structure.


union SearchResult = Product | BlogPost | SupportTicket

type Query {
  search(term: String!): [SearchResult!]!
}

Because a union type has no fields of its own, every query that queries it must use an inline fragment for every possible concrete type, even for a trivial field like id, unless it were identically named and typed across all member types. This restriction isn't a shortcoming, it's the logical consequence of a union type explicitly claiming no structural relationship between its members. If you find yourself repeating the same fields in every fragment, you probably actually needed an interface.

4. Implementing resolveType correctly

Both union types and interfaces need a function at runtime that decides, for a concrete PHP object, which GraphQL type it corresponds to. This function is called resolveType and is a required part of every union and interface type definition in webonyx/graphql-php. Without a correct resolveType implementation, the execution layer cannot know which fields apply to a returned object, and the query fails.


<?php

declare(strict_types=1);

namespace Mironsoft\GraphQlSchema\Type;

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\UnionType;
use Mironsoft\Catalog\Model\Product;
use Mironsoft\Blog\Model\BlogPost;
use Mironsoft\Support\Model\SupportTicket;

/**
 * Union type for heterogeneous search results across Product, BlogPost, and SupportTicket.
 */
final class SearchResultUnionType extends UnionType
{
    public string $name = 'SearchResult';

    /**
     * @param array{types: array<int, ObjectType>} $config Union config with member types
     */
    public function __construct(array $config)
    {
        $config['resolveType'] = $this->resolveType(...);
        parent::__construct($config);
    }

    /**
     * Maps a concrete PHP domain object to its matching GraphQL object type.
     *
     * @param mixed $value Domain object returned by a resolver, e.g. a Product entity
     * @param mixed $context Request context, unused here
     * @param mixed $info Resolve info, provides access to the schema
     * @return ObjectType Matching GraphQL object type for the given value
     * @throws \RuntimeException If no matching type is found for the given value
     */
    public function resolveType(mixed $value, mixed $context, mixed $info): ObjectType
    {
        return match (true) {
            $value instanceof Product => $info->schema->getType('Product'),
            $value instanceof BlogPost => $info->schema->getType('BlogPost'),
            $value instanceof SupportTicket => $info->schema->getType('SupportTicket'),
            default => throw new \RuntimeException(
                sprintf('No GraphQL type found for value of class %s.', $value::class)
            ),
        };
    }
}

The match (true) expression with instanceof checks is a robust pattern for resolveType, as long as the underlying PHP domain classes are unambiguous. For APIs with many union members, a registry that centrally manages the mapping between class and type name is worthwhile instead, so new member types don't need to be added to every resolveType individually. Interfaces use exactly the same resolveType signature, the only difference is that they additionally declare the shared field set on the interface itself.

5. Modeling search results as a union

A site-wide search is the textbook example for a union type. The resolver for the search field internally runs several independent searches, for example against the product index, the blog index, and the ticket system, and returns a mixed list of PHP objects of different classes. GraphQL then automatically handles mapping each element to its concrete type via resolveType, the resolver itself doesn't need to worry about type information.

For the client, this means the query asks for ... on Product { sku price }, ... on BlogPost { title excerpt }, and ... on SupportTicket { status priority } as inline fragments, combined with the built-in __typename field to recognize at runtime which fragment applies to which list item. This pattern scales well: a new search domain, such as customer reviews, is simply added as another union member, without breaking existing clients as long as they correctly evaluate __typename.

6. Interfaces for node-based APIs

One of the best-known applications of interfaces is the Relay-inspired Node interface, which declares a single field id: ID! and is implemented by virtually every persistent object type in the schema. A global node(id: ID!): Node query field lets clients fetch any object by its ID without knowing beforehand which concrete type it is, ideal for cache normalization in clients like Apollo or Relay.


interface Node {
  id: ID!
}

type Product implements Node {
  id: ID!
  sku: String!
}

type Customer implements Node {
  id: ID!
  email: String!
}

type Query {
  node(id: ID!): Node
}

This application of interfaces shows a structural difference from union types: the Node interface enforces that every implementing type has an ID, a genuine structural guarantee. A union type could not express that, since it makes no field requirements on its members. For globally uniquely identifiable entities, an interface is therefore almost always the better choice over a union type.

7. Applying fragments to union types and interfaces

For both union types and interfaces, clients fall back on inline fragments with the syntax ... on TypeName { ... } for type-specific fields. The difference lies in what is directly queryable outside the fragments: for an interface, the shared fields, for a union type, only the built-in __typename. Named fragments apply just as well to interface and union fields as inline fragments do, which significantly improves readability for complex search result queries.


query SiteSearch($term: String!) {
  search(term: $term) {
    __typename
    ... on Product {
      id
      sku
      price
    }
    ... on BlogPost {
      id
      title
      excerpt
    }
    ... on SupportTicket {
      id
      status
      priority
    }
  }
}

Without __typename in the query text, a client cannot reliably distinguish at runtime which fragment applies to which list item, especially in type-safe languages like TypeScript, where generated union types are built exactly on this field. Many GraphQL clients like Apollo Client therefore automatically inject __typename into every query, regardless of whether the developer explicitly requested it.

8. Common mistakes: missing __typename and incomplete resolveType

The most common mistake with union types is implementing resolveType only for the types known at development time and forgetting to extend the mapping logic when a new union member is added. The result is a runtime exception as soon as a resolver returns an object of the new type, often only visible in production once test data no longer covers the new cases.

A second common mistake concerns the frontend: if __typename is forgotten in a query on a union or interface field, the client cannot reliably match the corresponding fields from inline fragments at runtime, especially with generated TypeScript types built on discriminated unions. A third mistake is confusing the two concepts themselves: forcing an interface onto types without a genuine shared field set results in an interface with only a single meaningful field, usually a sign that a union type was actually needed.

9. Union types and interfaces compared directly

The decision between a union type and an interface depends on whether the involved types are structurally related. The table below summarizes the key differences.

Criterion Interface Union Type
Shared fields queryable without a fragment Yes No, only __typename
Enforces field structure on members Yes No
resolveType required Yes Yes
Fitting for content-unrelated types Unsuitable Ideal
Typical example Node interface, Searchable interface Search result, activity feed entry

As a rule of thumb: if two types share more than one or two coincidentally similarly named fields and that overlap makes domain sense, an interface is the right choice. If the types have nothing in common content-wise and only need to appear at the same point in the schema, a union type is the more honest modeling.

Mironsoft

GraphQL schema architecture and type-safe API modeling

Want to model heterogeneous data cleanly in your GraphQL schema?

We model union types and interfaces for search results, activity feeds, and node-based APIs, including robust resolveType and type-safe client integration.

Schema modeling

Interface vs. union type decisions for heterogeneous data models

resolveType implementation

Robust type mapping, including a registry for growing schemas

Client integration

__typename handling and fragment strategy for React and Vue

10. Summary

Union types and interfaces solve the same underlying problem, heterogeneous return values in GraphQL, but with different guarantees. An interface fits when multiple types genuinely share a common field set, as with the Relay-inspired Node interface or a Searchable interface for products, categories, and pages. A union type fits when the involved types have nothing in common content-wise, for example a site-wide search across products, blog posts, and support tickets.

Both constructs need a correctly implemented resolveType function that maps PHP objects to their GraphQL type at runtime, and both require inline fragments on the client side for type-specific fields. The built-in __typename field is essential for correctly matching fragments at runtime, especially in type-safe frontend stacks. Following the rule of thumb, shared field set equals interface, no field set equals union type, gets you the right modeling decision in most cases.

Union Types and Interfaces in GraphQL — Key Takeaways

Interface

For structurally related types with a genuine shared field set, directly queryable without a fragment.

Union type

For content-unrelated types without shared structure, only __typename queryable without a fragment.

resolveType

Maps PHP objects to their GraphQL type at runtime, required for both constructs.

__typename

Essential for clients to correctly match inline fragments at runtime.

11. FAQ: Union Types and Interfaces in GraphQL

1What's the difference between union types and interfaces?
An interface enforces a shared field set, a union type only offers __typename without a fragment.
2When interface instead of union type?
When multiple types share a common, domain-meaningful field set. Without overlap, union type is more honest.
3What does resolveType do?
Maps a concrete PHP object to its GraphQL object type so the execution layer knows which fields apply.
4Why do you need __typename?
Identifies the concrete type at runtime so clients can match inline fragments correctly.
5How do you model a site-wide search?
With a union type listing all searchable types, resolver returns a mixed list, resolveType handles mapping.
6What is the Node interface?
Relay-inspired interface with an id field, enables global fetching of any object via a node query field.
7Can a type implement multiple interfaces?
Yes, as long as all field requirements of all interfaces are met, common with Node plus a domain interface.
8What happens with incomplete resolveType?
A runtime exception as soon as a resolver returns an object of an unknown type, often only visible in production.
9Are inline fragments always needed on interfaces?
Only for type-specific fields outside the interface, shared fields are directly queryable.
10When is an interface actually a union type?
When only one meaningful shared field remains and the rest is completely different across types.