Union Types und Interfaces in GraphQL Schemas modellieren
AI generated
{ }
type
GraphQL · Schema Design · Typsystem · PHP
Union Types und Interfaces in GraphQL Schemas modellieren
Heterogene Ergebnisse typsicher abbilden, statt sie in einen Typ zu zwängen

Eine Suchfunktion, die Produkte, Kategorien und Seiten in einer Liste zurückgibt, sprengt jeden einzelnen Objekttyp. Union Types und Interfaces geben GraphQL die Werkzeuge, um solche heterogenen Ergebnisse ohne Kompromisse bei Typsicherheit oder Introspection zu modellieren, mit klaren Regeln, wann welches Werkzeug passt.

19 Min. Lesezeit Union Types · Interfaces · resolveType · Inline-Fragmente webonyx/graphql-php · PHP 8.4

1. Warum ein einzelner Typ für heterogene Ergebnisse nicht reicht

Ein GraphQL-Feld hat immer genau einen deklarierten Rückgabetyp. Solange dieser Typ ein einzelnes Objekt wie Product oder eine Liste davon ist, funktioniert das reibungslos. Sobald ein Feld aber grundsätzlich verschiedene Objektarten zurückgeben können muss, etwa eine Suche, die Produkte, Kategorien und CMS-Seiten gleichzeitig durchsucht, reicht ein einzelner Objekttyp nicht mehr aus. Ohne Union Types und Interfaces bliebe nur ein erzwungener gemeinsamer Typ mit vielen optionalen Feldern, von denen die meisten für jedes einzelne Ergebnis null sind.

Dieser Ansatz mit einem künstlichen Sammel-Typ verschleiert die tatsächliche Struktur der Daten und zwingt Clients, anhand von Heuristiken zu erraten, um welche Art von Ergebnis es sich handelt. GraphQLs Typsystem bietet dafür zwei saubere Alternativen: Interfaces für Typen, die gemeinsame Felder teilen, und Union Types für Typen, die inhaltlich nichts miteinander zu tun haben. Beide lösen das Problem heterogener Rückgabewerte, aber mit unterschiedlichen Garantien für Clients.

2. Interfaces: gemeinsame Felder für verwandte Typen

Ein Interface in GraphQL definiert eine Menge von Feldern, die jeder implementierende Typ garantiert bereitstellen muss. Das ist das GraphQL-Äquivalent zu Interfaces in objektorientierten Sprachen: Ein Client, der ein Feld vom Interface-Typ abfragt, kann die im Interface deklarierten Felder ohne Fragmente direkt abfragen, weil garantiert ist, dass jeder mögliche konkrete Typ sie besitzt. Typische Kandidaten für ein Interface sind verwandte Entitäten mit überlappender Struktur, etwa Product, Category und CmsPage, die alle id, url und metaTitle besitzen.


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!
}

Der entscheidende Vorteil eines Interface gegenüber einem Union Type: Ein Client kann id, url und metaTitle direkt auf dem Interface-Feld abfragen, ohne für jeden konkreten Typ ein eigenes Inline-Fragment schreiben zu müssen. Nur für typspezifische Felder wie sku oder content sind Fragmente weiterhin nötig. Das macht Interfaces zur richtigen Wahl, sobald mehrere Typen tatsächlich eine gemeinsame Feldmenge teilen, nicht nur zufällig ähnliche Namen tragen.

3. Union Types: völlig unterschiedliche Typen in einem Feld

Ein Union Type definiert dagegen keine gemeinsamen Felder. Er beschreibt lediglich, welche Objekttypen an dieser Stelle im Schema erlaubt sind, ohne irgendeine Feldüberschneidung zu erzwingen oder zu erwarten. Das ist die richtige Wahl, wenn die beteiligten Typen inhaltlich nichts gemeinsam haben, etwa ein Suchergebnis, das entweder ein Product oder eine BlogPost oder ein SupportTicket sein kann, drei Entitäten ohne sinnvoll geteilte Struktur.


union SearchResult = Product | BlogPost | SupportTicket

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

Weil ein Union Type keine eigenen Felder besitzt, muss jede Query, die ihn abfragt, für jeden möglichen konkreten Typ ein Inline-Fragment verwenden, sogar für ein banales Feld wie id, falls es nicht über alle Mitgliedstypen identisch benannt und typisiert wäre. Diese Einschränkung ist kein Mangel, sondern die konsequente Folge davon, dass ein Union Type explizit keine strukturelle Verwandtschaft zwischen seinen Mitgliedern behauptet. Wer feststellt, dass er ständig dieselben Felder in jedem Fragment wiederholt, hat vermutlich eigentlich ein Interface gebraucht.

4. resolveType richtig implementieren

Sowohl Union Types als auch Interfaces benötigen zur Laufzeit eine Funktion, die für ein konkretes PHP-Objekt entscheidet, welchem GraphQL-Typ es entspricht. Diese Funktion heißt resolveType und ist in webonyx/graphql-php Pflichtbestandteil jeder Union- und Interface-Typdefinition. Ohne eine korrekte resolveType-Implementierung kann die Ausführungsebene nicht wissen, welche Felder auf ein zurückgegebenes Objekt anwendbar sind, und die Query schlägt fehl.


<?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)
            ),
        };
    }
}

Der match (true)-Ausdruck mit instanceof-Prüfungen ist ein robustes Muster für resolveType, solange die zugrunde liegenden PHP-Domänenklassen eindeutig sind. Bei APIs mit vielen Union-Mitgliedern lohnt sich stattdessen eine Registry, die die Zuordnung zwischen Klasse und Typnamen zentral verwaltet, damit neue Mitgliedstypen nicht in jedem resolveType einzeln nachgetragen werden müssen. Interfaces nutzen exakt dieselbe resolveType-Signatur, der einzige Unterschied ist, dass sie zusätzlich die gemeinsame Feldmenge im Interface selbst deklarieren.

5. Suchergebnisse als Union modellieren

Eine sitegreifende Suche ist das Lehrbuchbeispiel für einen Union Type. Der Resolver für das search-Feld führt intern mehrere unabhängige Suchen aus, etwa gegen den Produktindex, den Blog-Index und das Ticketsystem, und liefert eine gemischte Liste von PHP-Objekten unterschiedlicher Klassen zurück. GraphQL übernimmt danach automatisch die Zuordnung jedes Elements zu seinem konkreten Typ über resolveType, der Resolver selbst muss sich um Typinformationen nicht kümmern.

Für den Client bedeutet das: Die Query fragt ... on Product { sku price }, ... on BlogPost { title excerpt } und ... on SupportTicket { status priority } als Inline-Fragmente ab, kombiniert mit dem eingebauten __typename-Feld, um zur Laufzeit zu erkennen, welches Fragment für welches Listenelement greift. Dieses Muster skaliert gut: Ein neuer Suchbereich, etwa Kundenrezensionen, wird einfach als weiteres Union-Mitglied ergänzt, ohne bestehende Clients zu brechen, solange sie __typename korrekt auswerten.

6. Interfaces für Knoten-basierte APIs

Eine der bekanntesten Anwendungen von Interfaces ist das Relay-inspirierte Node-Interface, das ein einziges Feld id: ID! deklariert und von praktisch jedem persistenten Objekttyp im Schema implementiert wird. Ein globales node(id: ID!): Node-Query-Feld ermöglicht es Clients, ein beliebiges Objekt anhand seiner ID nachzuladen, ohne vorher zu wissen, um welchen konkreten Typ es sich handelt, ideal für Cache-Normalisierung in Clients wie Apollo oder 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
}

Diese Anwendung von Interfaces zeigt einen strukturellen Unterschied zu Union Types: Das Node-Interface erzwingt, dass jeder implementierende Typ eine ID besitzt, eine echte strukturelle Garantie. Ein Union Type könnte das nicht ausdrücken, weil er keine Feldanforderungen an seine Mitglieder stellt. Für global eindeutig identifizierbare Entitäten ist ein Interface deshalb fast immer die richtigere Wahl als ein Union Type.

7. Fragmente auf Union Types und Interfaces anwenden

Sowohl bei Union Types als auch bei Interfaces greifen Clients für typspezifische Felder auf Inline-Fragmente mit der Syntax ... on TypeName { ... } zurück. Der Unterschied liegt darin, was außerhalb der Fragmente direkt abfragbar ist: bei einem Interface die gemeinsamen Felder, bei einem Union Type nur das eingebaute __typename. Named Fragments lassen sich genauso auf Interface- und Union-Felder anwenden wie Inline-Fragmente, was bei komplexen Suchergebnis-Queries die Lesbarkeit deutlich verbessert.


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

Ohne __typename im Query-Text kann ein Client zur Laufzeit nicht zuverlässig unterscheiden, welches Fragment für welches Listenelement zutrifft, insbesondere in typsicheren Sprachen wie TypeScript, wo generierte Union-Typen genau auf diesem Feld basieren. Viele GraphQL-Clients wie Apollo Client fügen __typename deshalb automatisch in jede Query ein, unabhängig davon, ob der Entwickler es explizit angefordert hat.

8. Typische Fehler: fehlendes __typename und unvollständiges resolveType

Der häufigste Fehler bei Union Types ist, resolveType nur für die zum Entwicklungszeitpunkt bekannten Typen zu implementieren und beim Hinzufügen eines neuen Union-Mitglieds zu vergessen, die Zuordnungslogik zu erweitern. Das Resultat ist eine Runtime-Exception, sobald ein Resolver ein Objekt des neuen Typs zurückgibt, oft erst in Produktion sichtbar, wenn Testdaten die neuen Fälle nicht abdecken.

Ein zweiter verbreiteter Fehler betrifft das Frontend: Wird __typename in einer Query auf ein Union- oder Interface-Feld vergessen, kann der Client zur Laufzeit die passenden Felder aus den Inline-Fragmenten nicht zuverlässig zuordnen, besonders bei generierten TypeScript-Typen, die auf Discriminated Unions basieren. Ein dritter Fehler ist die Verwechslung der beiden Konzepte selbst: Wird ein Interface für Typen ohne echte gemeinsame Feldmenge erzwungen, entsteht ein Interface mit nur einem einzigen sinnvollen Feld, was meist ein Zeichen dafür ist, dass eigentlich ein Union Type gebraucht wurde.

9. Union Types und Interfaces im direkten Vergleich

Die Entscheidung zwischen Union Type und Interface hängt davon ab, ob die beteiligten Typen strukturell verwandt sind. Die folgende Tabelle fasst die wichtigsten Unterschiede zusammen.

Kriterium Interface Union Type
Gemeinsame Felder ohne Fragment abfragbar Ja Nein, nur __typename
Erzwingt Feldstruktur bei Mitgliedern Ja Nein
resolveType erforderlich Ja Ja
Passend für inhaltlich fremde Typen Ungeeignet Ideal
Typisches Beispiel Node-Interface, Searchable-Interface Suchergebnis, Activity-Feed-Eintrag

Als Faustregel gilt: Wenn zwei Typen mehr als ein oder zwei zufällig gleich benannte Felder teilen und diese Überschneidung fachlich Sinn ergibt, ist ein Interface die richtige Wahl. Wenn die Typen inhaltlich nichts miteinander zu tun haben und nur an derselben Stelle im Schema erscheinen müssen, ist ein Union Type die ehrlichere Modellierung.

Mironsoft

GraphQL-Schema-Architektur und typsichere API-Modellierung

Heterogene Daten sauber im GraphQL-Schema abbilden?

Wir modellieren Union Types und Interfaces für Suchergebnisse, Aktivitäts-Feeds und Knoten-basierte APIs, inklusive robustem resolveType und typsicherer Client-Integration.

Schema-Modellierung

Interface vs. Union Type Entscheidung für heterogene Datenmodelle

resolveType-Implementierung

Robuste Typ-Zuordnung inklusive Registry für wachsende Schemas

Client-Integration

__typename-Handling und Fragment-Strategie für React und Vue

10. Zusammenfassung

Union Types und Interfaces lösen dasselbe Grundproblem, heterogene Rückgabewerte in GraphQL, aber mit unterschiedlichen Garantien. Ein Interface eignet sich, wenn mehrere Typen tatsächlich eine gemeinsame Feldmenge teilen, wie beim Relay-inspirierten Node-Interface oder einem Searchable-Interface für Produkte, Kategorien und Seiten. Ein Union Type eignet sich, wenn die beteiligten Typen inhaltlich nichts miteinander zu tun haben, etwa bei einer sitegreifenden Suche über Produkte, Blogartikel und Support-Tickets.

Beide Konstrukte benötigen eine korrekt implementierte resolveType-Funktion, die PHP-Objekte zur Laufzeit ihrem GraphQL-Typ zuordnet, und beide erfordern auf Client-Seite Inline-Fragmente für typspezifische Felder. Das eingebaute __typename-Feld ist dabei unverzichtbar, um Fragmente zur Laufzeit korrekt zuzuordnen, besonders in typsicheren Frontend-Stacks. Wer die Faustregel befolgt, gemeinsame Feldmenge gleich Interface, keine Feldmenge gleich Union Type, trifft in den meisten Fällen die richtige Modellierungsentscheidung.

Union Types und Interfaces in GraphQL — Das Wichtigste auf einen Blick

Interface

Für strukturell verwandte Typen mit echter gemeinsamer Feldmenge, direkt ohne Fragment abfragbar.

Union Type

Für inhaltlich fremde Typen ohne gemeinsame Struktur, nur __typename ohne Fragment abfragbar.

resolveType

Ordnet PHP-Objekte zur Laufzeit ihrem GraphQL-Typ zu, Pflicht bei beiden Konstrukten.

__typename

Unverzichtbar für Clients, um Inline-Fragmente zur Laufzeit korrekt zuzuordnen.

11. FAQ: Union Types und Interfaces in GraphQL

1Was ist der Unterschied zwischen Union Types und Interfaces?
Ein Interface erzwingt eine gemeinsame Feldmenge, ein Union Type nur __typename ohne Fragment abfragbar.
2Wann Interface statt Union Type?
Wenn mehrere Typen eine gemeinsame, fachlich sinnvolle Feldmenge teilen. Ohne Überschneidung ist Union Type ehrlicher.
3Was macht resolveType?
Ordnet ein konkretes PHP-Objekt seinem GraphQL-Objekttyp zu, damit die Ausführungsebene weiß, welche Felder anwendbar sind.
4Wozu braucht man __typename?
Identifiziert zur Laufzeit den konkreten Typ, damit Clients Inline-Fragmente korrekt zuordnen können.
5Wie modelliert man eine sitegreifende Suche?
Mit einem Union Type, der alle durchsuchbaren Typen auflistet, Resolver liefert gemischte Liste, resolveType übernimmt Zuordnung.
6Was ist das Node-Interface?
Relay-inspiriertes Interface mit einem Feld id, erlaubt globales Nachladen beliebiger Objekte über ein node-Query-Feld.
7Mehrere Interfaces gleichzeitig implementieren?
Ja, solange alle Feldanforderungen aller Interfaces erfüllt werden, üblich bei Node plus fachlichem Interface.
8Was passiert bei unvollständigem resolveType?
Eine Runtime-Exception, sobald ein Resolver ein Objekt eines unbekannten Typs zurückgibt, oft erst in Produktion sichtbar.
9Sind Inline-Fragmente bei Interfaces immer nötig?
Nur für typspezifische Felder außerhalb des Interface, gemeinsame Felder sind direkt abfragbar.
10Wann ist ein Interface eigentlich ein Union Type?
Wenn nur ein einziges sinnvolles gemeinsames Feld übrig bleibt und der Rest komplett unterschiedlich ist.