4. Februar 2026
Dynamische GraphQL-Queries zur Laufzeit
Wenn Benutzer selbst wählen, welche Spalten angezeigt werden, können GraphQL-Queries nicht zur Build-Zeit generiert werden. Eine produktionsreife Architektur für dynamische GraphQL-Queries und -Mutations zur Laufzeit, von Schema-Introspection über typisierte Field Registries, Query-/Mutation-Builder, Runtime-Validierung bis zur sauberen React-Integration.
Sascha Becker
Author25 Min. Lesezeit
Dynamische GraphQL-Queries zur Laufzeit
In einer idealen Welt ist jede GraphQL-Query statisch. Man schreibt sie einmal, ein Codegen-Tool wie graphql-codegen oder gqlgen macht einen typisierten Hook daraus, und man denkt nie wieder über den Query-String nach.
Aber manche Anwendungen leben nicht in dieser Welt.
Man denke an eine Tabelle, in der Benutzer per Checkboxen wählen, welche Spalten angezeigt werden. Oder ein Admin-Dashboard, in dem jede Rolle unterschiedliche Felder sieht. Oder einen Report-Builder, in dem Filter und Gruppierungen zur Laufzeit gewählt werden. Die Query existiert nicht, bis jemand klickt.
Dieser Artikel beschreibt eine saubere, produktionsreife Architektur für genau dieses Szenario.
Als Package verfügbar
Die in diesem Artikel beschriebene Architektur ist jetzt als npm-Paket
verfügbar:
@saschb2b/gql-drift. Es
implementiert die komplette Pipeline — Introspection, Field Registry,
Query-/Mutation-Builder, Flatten/Unflatten, Zod-Validierung und
React-Integration — sodass man sie nicht von Grund auf selbst bauen muss. Der
Artikel unten erklärt die Konzepte; das Paket liefert eine fertige
Implementierung.
Die verlockende Abkürzung
Der erste Instinkt ist String-Konkatenation:
tsfunction buildQuery(fields: string[]) {return `query {orders {${fields.join("\n ")}}}`;}const data = await fetchGraphQL(buildQuery(selectedFields));// data ist `any`
Das funktioniert für einen Prototyp. Darüber hinaus schafft es Probleme:
- Keine Typsicherheit.
dataistany. Jeder Zugriff ist unvalidiert. - Keine Validierung. Übergibt man
"nonExistentField", bekommt man einen Runtime-GraphQL-Fehler. - Injection-Fläche. Wenn Feldnamen ohne Validierung aus Benutzereingaben kommen, vertraut man dem Client die Query-Struktur an.
- Unmöglich zu refactoren. Benennt man ein Feld im Schema um, warnt nichts in der Codebase.
- Kein Nesting. Echte Schemas haben verschachtelte Objekte (
address { city state }). String-Joining kann das nicht ausdrücken.
Architektur-Überblick
Der saubere Ansatz trennt Verantwortlichkeiten in Schichten:
DIAGRAM
Jede Schicht hat eine Aufgabe:
| Schicht | Verantwortung |
|---|---|
| Introspection | Schema lesen: Typen, Felder, Verschachtelung, Skalartypen und verfügbare Mutations entdecken |
| Field Registry | Strukturierte Ausgabe: mappt introspektierte Felder auf UI-Labels, GraphQL-Pfade und Formatierungstypen |
| Query Builder | Reine Funktion: ausgewählte Felder rein, gültiger GraphQL-Query-String raus |
| Mutation Builder | Reine Funktion: geänderte Felder rein, gültiger GraphQL-Mutation-String raus |
| Runtime-Validierung | Zod-Schema: validiert API-Antworten und Benutzereingaben vor der Mutation |
| Data Layer | Transport: Fetch-Wrapper oder TanStack Query mit korrekten Cache Keys |
| UI Layer | Checkboxen steuern die Auswahl, Tabelle rendert Ergebnis, Edits lösen Mutations aus |
Vom Schema starten: Introspection
Bei statischen Queries liest Codegen deine .graphql-Dateien und generiert typisierte Hooks. Bei dynamischen Queries gibt es keine .graphql-Dateien: aber das Schema selbst ist weiterhin die Source of Truth. Der erste Schritt ist, es zu lesen.
Die Introspection Query
Jede GraphQL-API unterstützt Introspection (sofern nicht explizit deaktiviert). Man kann sie fragen: "Welche Typen hast du? Welche Felder hat jeder Typ?"
tsconst INTROSPECTION_QUERY = `query IntrospectType($typeName: String!) {__type(name: $typeName) {namefields {nametype {namekindofType {namekindofType {namekind}}}}}}`;async function introspectType(typeName: string) {const res = await fetch("/graphql", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({query: INTROSPECTION_QUERY,variables: { typeName },}),});const json = await res.json();return json.data.__type;}
introspectType("Order") liefert die komplette Feldstruktur zurück. Feldnamen, Skalartypen, verschachtelte Objekte, alles was das Schema definiert.
Introspection in eine Field Registry parsen
Die rohe Introspection-Antwort ist ausführlich. Der nächste Schritt ist die Transformation in die flache FieldDefinition[]-Struktur, mit der der Rest der Pipeline arbeitet:
ts// --- Typen für die Introspection-Antwort ---interface IntrospectionType {name: string | null;kind: string;ofType?: IntrospectionType;}interface IntrospectionField {name: string;type: IntrospectionType;}interface IntrospectionResult {name: string;fields: IntrospectionField[];}// --- Field Definition für die gesamte Pipeline ---interface FieldDefinition {key: string;label: string;graphqlPath: string;type: "string" | "number" | "date" | "boolean" | "enum";enumValues?: string[]; // ["PENDING", "SHIPPED", "DELIVERED"]}// --- Hilfsfunktionen ---function capitalize(s: string): string {return s.charAt(0).toUpperCase() + s.slice(1);}function formatLabel(fieldName: string): string {// camelCase → "Camel Case"return fieldName.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (s) => s.toUpperCase());}// NON_NULL und LIST Wrapper auspacken, um den eigentlichen Typ zu bekommenfunction unwrapType(t: IntrospectionType): IntrospectionType {while (t.kind === "NON_NULL" || t.kind === "LIST") {t = t.ofType!;}return t;}// GraphQL-Skalarnamen auf unser vereinfachtes Typsystem mappenconst SCALAR_MAP: Record<string, FieldDefinition["type"]> = {String: "string",Int: "number",Float: "number",Boolean: "boolean",DateTime: "date",ID: "string",};// --- Registry Builder ---function buildFieldRegistry(introspection: IntrospectionResult,prefix = "",pathPrefix = "",): FieldDefinition[] {const fields: FieldDefinition[] = [];for (const field of introspection.fields) {if (field.name === "id") continue; // id wird immer automatisch eingeschlossenconst unwrapped = unwrapType(field.type);const graphqlPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;const key = prefix ? `${prefix}${capitalize(field.name)}` : field.name;if (unwrapped.kind === "SCALAR") {const mappedType = SCALAR_MAP[unwrapped.name!];if (mappedType) {fields.push({key,label: formatLabel(field.name),graphqlPath,type: mappedType,});}} else if (unwrapped.kind === "ENUM") {fields.push({key,label: formatLabel(field.name),graphqlPath,type: "enum",enumValues: [], // wird per Enum-Introspection befüllt});} else if (unwrapped.kind === "OBJECT") {// Verschachteltes Objekt - wird per rekursiver Introspection behandelt (siehe unten)}}return fields;}
Build-Time vs Runtime Introspection
Es gibt zwei Optionen für den Zeitpunkt der Introspection:
- Build-Time-Script: Introspection während CI/Build ausführen, eine statische
fieldRegistry.ts-Datei generieren. Das kommt dem Codegen-Workflow am nächsten und gibt Compile-Time-Garantien. Ideal für Schemas, die sich selten ändern. - Runtime-Introspection: die Introspection Query beim App-Start aufrufen (oder wenn eine bestimmte Seite geladen wird). Ideal für Multi-Tenant-Apps, bei denen jeder Tenant ein anderes Schema haben könnte, oder wenn sich das Schema häufig ohne Redeployments ändert.
Build-Time-Generierungsscript
Für die meisten Projekte ist ein Build-Time-Script die bessere Wahl. Es läuft einmal, gibt eine typisierte Datei aus, und man bekommt IDE-Autocompletion:
scripts/generate-field-registry.tsimport { writeFileSync } from "fs";async function main() {const type = await introspectType("Order");const fields = buildFieldRegistry(type);const output = `// AUTO-GENERIERT - nicht manuell bearbeiten// Ausfuehren: npx tsx scripts/generate-field-registry.tsimport type { FieldDefinition } from "../types";export const ORDER_FIELDS: FieldDefinition[] = ${JSON.stringify(fields, null, 2)};`;writeFileSync("src/generated/orderFields.ts", output);console.log(`${fields.length} Field Definitions für Order generiert`);}main();
In der package.json hinzufügen:
json{"scripts": {"generate:fields": "tsx scripts/generate-field-registry.ts"}}
Jetzt produziert pnpm generate:fields eine typisierte Field Registry aus dem Live-Schema: ähnlich wie gqlgen generate für statische Queries funktioniert. Der Unterschied: Diese Registry füttert einen Runtime Query Builder statt statischer typisierter Hooks.
Verschachtelte Typen behandeln
Echte Schemas haben Verschachtelung. Die Introspection für Order könnte offenbaren, dass shippingAddress ein OBJECT-Typ mit eigenen Feldern ist. Der Registry-Generator behandelt das durch eine Ebene Rekursion:
ts// Waehrend des Introspection-Parsings, wenn ein OBJECT-Feld auftaucht:if (nestedTypeName) {const nestedType = await introspectType(nestedTypeName);const nestedFields = buildFieldRegistry(nestedType,field.name, // prefix: "shippingAddress" → Keys wie "shippingAddressCity"field.name, // pathPrefix: "shippingAddress" → Pfade wie "shippingAddress.city");fields.push(...nestedFields);}
Das flacht shippingAddress.city in eine einzelne FieldDefinition mit key: "shippingAddressCity" und graphqlPath: "shippingAddress.city" ab. Die UI sieht flache Checkboxen; der Query Builder rekonstruiert die Verschachtelung.
Rekursionstiefe begrenzen
Nicht endlos in verschachtelte Objekte rekursieren. Eine Ebene tief deckt die meisten Anwendungsfälle ab. Tiefere Verschachtelung bedeutet meist, dass der Benutzer eine andere UI braucht (einen Tree-Picker, eine Sub-Tabelle) statt flacher Checkboxen.
Die Field Registry
Nach Introspection und Generierung hat man ein FieldDefinition[], die strukturierte Ausgabe, die alles Nachgelagerte antreibt.
So sieht die generierte Registry für einen Order-Typ aus:
tsconst ORDER_FIELDS: FieldDefinition[] = [{key: "orderNumber",label: "Bestellnummer",graphqlPath: "orderNumber",type: "string",},{key: "customerName",label: "Kundenname",graphqlPath: "customerName",type: "string",},{ key: "status", label: "Status", graphqlPath: "status", type: "string" },{ key: "total", label: "Summe", graphqlPath: "total", type: "number" },{key: "currency",label: "Waehrung",graphqlPath: "currency",type: "string",},{key: "createdAt",label: "Erstellt Am",graphqlPath: "createdAt",type: "date",},{key: "shippingAddressCity",label: "Stadt",graphqlPath: "shippingAddress.city",type: "string",},{key: "shippingAddressCountry",label: "Land",graphqlPath: "shippingAddress.country",type: "string",},];
Die generierte Registry anreichern
Die auto-generierten Labels von formatLabel sind brauchbar, aber nicht immer ideal ("Kundenname" ist okay, "Erstellt Am" sollte vielleicht "Erstellt" sein). Man kann eine Override-Schicht hinzufügen:
tsconst LABEL_OVERRIDES: Partial<Record<string, string>> = {orderNumber: "Bestellnr.",createdAt: "Erstellt",shippingAddressCity: "Versandstadt",shippingAddressCountry: "Versandland",};const ORDER_FIELDS_ENRICHED = ORDER_FIELDS.map((f) => ({...f,label: LABEL_OVERRIDES[f.key] ?? f.label,}));
Das lässt die generierte Datei unberührt (neu ausführbar), während man Kontrolle über die UI-Labels behält.
Warum nicht einfach keyof Order?
Weil die Registry mehr tut als Feldnamen aufzulisten. Sie mappt flache UI-Keys
("shippingAddressCity") auf verschachtelte GraphQL-Pfade
("shippingAddress.city"), definiert Anzeige-Labels und deklariert Typen für
die Formatierung. Ein einfaches keyof kann das nicht ausdrücken, und mit
introspection-basierter Generierung muss man es nicht von Hand pflegen.
Der Query Builder
Der Query Builder ist eine reine Funktion. Er nimmt eine Liste von Field Definitions und gibt einen gültigen GraphQL-Query-String zurück. Die zentrale Herausforderung ist das Handling verschachtelter Felder.
tsfunction buildOrderQuery(fields: FieldDefinition[]): string {// Immer id einschließenconst paths = ["id", ...fields.map((f) => f.graphqlPath)];// Verschachtelte Pfade gruppieren: "shippingAddress.city" → { shippingAddress: ["city"] }const roots: string[] = [];const nested = new Map<string, string[]>();for (const path of paths) {const dot = path.indexOf(".");if (dot === -1) {roots.push(path);} else {const parent = path.slice(0, dot);const child = path.slice(dot + 1);if (!nested.has(parent)) nested.set(parent, []);nested.get(parent)!.push(child);}}// Selection Set aufbauenconst selections = [...roots,...[...nested.entries()].map(([parent, children]) => `${parent} { ${children.join(" ")} }`,),];return `query GetOrders($filter: OrderFilter) {orders(filter: $filter) {${selections.join("\n ")}}}`;}
Für die Auswahl ["orderNumber", "customerName", "shippingAddressCity", "shippingAddressCountry"] produziert das:
graphqlquery GetOrders($filter: OrderFilter) {orders(filter: $filter) {idorderNumbercustomerNameshippingAddress {citycountry}}}
Halte es rein
Der Query Builder hat keine Seiteneffekte, keinen State, keine Abhängigkeiten. Das macht ihn trivial testbar. Felder rein, String raus. Snapshot-Tests funktionieren hier gut.
Tiefere Verschachtelung
Wenn das Schema tiefere Verschachtelung hat (z.B. shippingAddress.coordinates.lat), kann man den Builder rekursiv machen. Für die meisten Anwendungen reicht eine Ebene Verschachtelung, widerstehe dem Drang, einen allgemeinen Query-AST zu bauen, es sei denn, du brauchst ihn wirklich.
Typisierung des Ergebnisses
Hier werden die Grenzen von TypeScript sichtbar. Die Query-Form hängt von der Laufzeit-Auswahl ab, also kann man kein volles Compile-Time-Narrowing haben. Aber es gibt Optionen entlang eines Spektrums.
Option 1: Partial<Order>: Einfach und ehrlich
tstype OrderQueryResult = Pick<Order, "id"> & Partial<Omit<Order, "id">>;
Jedes Feld außer id könnte undefined sein. Das zwingt zur Behandlung der Abwesenheit, was korrekt ist, man weiß zur Compile-Zeit wirklich nicht, welche Felder ausgewählt wurden.
Option 2: Generischer Selection-Typ
Wenn die Auswahl am Aufrufpunkt bekannt ist:
tstype DynamicResult<T, K extends keyof T> = Pick<T, "id" & keyof T> & Pick<T, K>;// Wenn die Auswahl am Aufrufpunkt bekannt ist:const fields = ["orderNumber", "status"] as const;type Result = DynamicResult<Order, (typeof fields)[number]>;// = { id: string; orderNumber: string; status: "pending" | "shipped" | ... }
Das funktioniert in kontrollierten Szenarien (z.B. Presets, gespeicherte Ansichten). Es hilft nicht, wenn die Auswahl zur Laufzeit durch Benutzerinteraktion kommt.
Option 3: Runtime-Validierung: Das echte Sicherheitsnetz
Typen verschwinden zur Laufzeit
TypeScript-Typen werden zur Compile-Zeit gelöscht. Bei dynamischen Queries, deren Form vom Benutzer bestimmt wird, ist Runtime-Validierung das eigentliche Sicherheitsnetz: Typen sind Entwickler-Komfort obendrauf.
Baue ein Zod-Schema dynamisch aus derselben Field Registry:
tsimport { z } from "zod";const FIELD_VALIDATORS: Record<FieldDefinition["type"], z.ZodTypeAny> = {string: z.string(),number: z.number(),date: z.string().datetime(),boolean: z.boolean(),enum: z.string(), // wird mit z.enum() überschrieben wenn enumValues verfügbar};function buildResultSchema(fields: FieldDefinition[]) {const shape: Record<string, z.ZodTypeAny> = { id: z.string() };for (const field of fields) {shape[field.key] = FIELD_VALIDATORS[field.type];}return z.object(shape);}// Verwendung: erst flatten, dann validierenconst flatRows = rawData.orders.map((order) =>flattenOrder(order, selectedFields),);const schema = z.array(buildResultSchema(selectedFields));const validated = schema.parse(flatRows); // wirft, wenn die Form nicht passt
Das gibt echte Laufzeit-Garantien, wenn die API etwas Unerwartetes zurückgibt (fehlendes Feld, falscher Typ), fängt man es sofort nach dem Flatten ab, statt irgendwo im Table-Rendering zu crashen.
Der Data Layer
Einfacher Fetch-Wrapper
Für ein direktes Setup mit use() + Suspense (siehe den use()-Hook Artikel):
tsconst queryCache = new Map<string, Promise<unknown>>();function fetchOrders(fields: FieldDefinition[], filter?: OrderFilter) {const cacheKey =fields.map((f) => f.key).toSorted().join(",") +"|" +JSON.stringify(filter);if (!queryCache.has(cacheKey)) {queryCache.set(cacheKey,fetch("/graphql", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({query: buildOrderQuery(fields),variables: { filter },}),}).then((r) => r.json()).then((json) => {const rows = json.data.orders.map((order: Record<string, unknown>) =>flattenOrder(order, fields),);return z.array(buildResultSchema(fields)).parse(rows);}),);}return queryCache.get(cacheKey)!;}
TanStack Query Integration
Wenn man Cache-Invalidierung, Background-Refetching oder Pagination braucht:
tsfunction useOrders(fields: FieldDefinition[], filter?: OrderFilter) {const fieldKeys = fields.map((f) => f.key).toSorted();return useQuery({queryKey: ["orders", fieldKeys, filter],queryFn: async () => {const res = await fetch("/graphql", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({query: buildOrderQuery(fields),variables: { filter },}),});const json = await res.json();const rows = json.data.orders.map((order: Record<string, unknown>) =>flattenOrder(order, fields),);return z.array(buildResultSchema(fields)).parse(rows);},enabled: fields.length > 0,});}
Cache Key muss die Auswahl enthalten
Wenn der queryKey die ausgewählten Felder nicht enthält, zeigt ein Wechsel
von 3 auf 5 Spalten die veralteten 3-Spalten-Daten, bis die neue Query fertig
ist. Die sortierte Feldliste im Key stellt sicher, dass jede einzigartige
Auswahl ihren eigenen Cache-Eintrag bekommt.
Alles zusammen
Hier ist der komplette Flow. Checkboxen bis Tabelle:
tsx"use client";import { useState, useMemo } from "react";import { useQuery } from "@tanstack/react-query";// --- Typen ---interface FieldDefinition {key: string;label: string;graphqlPath: string;type: "string" | "number" | "date" | "boolean" | "enum";}// --- Field Registry ---const ORDER_FIELDS: FieldDefinition[] = [{key: "orderNumber",label: "Bestellnr.",graphqlPath: "orderNumber",type: "string",},{key: "customerName",label: "Kunde",graphqlPath: "customerName",type: "string",},{ key: "status", label: "Status", graphqlPath: "status", type: "string" },{ key: "total", label: "Summe", graphqlPath: "total", type: "number" },{key: "currency",label: "Waehrung",graphqlPath: "currency",type: "string",},{key: "createdAt",label: "Erstellt",graphqlPath: "createdAt",type: "date",},{key: "shippingAddressCity",label: "Stadt",graphqlPath: "shippingAddress.city",type: "string",},{key: "shippingAddressCountry",label: "Land",graphqlPath: "shippingAddress.country",type: "string",},];// --- Query Builder ---function buildOrderQuery(fields: FieldDefinition[]): string {const paths = ["id", ...fields.map((f) => f.graphqlPath)];const roots: string[] = [];const nested = new Map<string, string[]>();for (const path of paths) {const dot = path.indexOf(".");if (dot === -1) {roots.push(path);} else {const parent = path.slice(0, dot);const child = path.slice(dot + 1);if (!nested.has(parent)) nested.set(parent, []);nested.get(parent)!.push(child);}}const selections = [...roots,...[...nested.entries()].map(([parent, children]) => `${parent} { ${children.join(" ")} }`,),];return `query GetOrders($filter: OrderFilter) {orders(filter: $filter) {${selections.join("\n ")}}}`;}// --- Flattener (verschachtelte Antwort → flache Zeile) ---function flattenOrder(order: Record<string, unknown>,fields: FieldDefinition[],) {const row: Record<string, unknown> = { id: order.id };for (const field of fields) {const parts = field.graphqlPath.split(".");let value: unknown = order;for (const part of parts) {value = (value as Record<string, unknown>)?.[part];}row[field.key] = value;}return row;}// --- Komponente ---export function OrderTable() {const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set(["orderNumber", "customerName", "status"]),);const selectedFields = useMemo(() => ORDER_FIELDS.filter((f) => selectedKeys.has(f.key)),[selectedKeys],);const { data, isLoading, error } = useQuery({queryKey: ["orders", [...selectedKeys].sort()],queryFn: async () => {const res = await fetch("/graphql", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({query: buildOrderQuery(selectedFields),}),});return res.json();},enabled: selectedKeys.size > 0,});const rows = useMemo(() => {if (!data?.data?.orders) return [];return data.data.orders.map((order: Record<string, unknown>) =>flattenOrder(order, selectedFields),);}, [data, selectedFields]);const toggleField = (key: string) => {setSelectedKeys((prev) => {const next = new Set(prev);if (next.has(key)) next.delete(key);else next.add(key);return next;});};return (<div>{/* Spaltenauswahl */}<fieldset><legend>Spalten</legend>{ORDER_FIELDS.map((field) => (<label key={field.key} style={{ marginRight: 16 }}><inputtype="checkbox"checked={selectedKeys.has(field.key)}onChange={() => toggleField(field.key)}/>{field.label}</label>))}</fieldset>{/* Datentabelle */}{error && <div>Fehler: {error.message}</div>}{isLoading && <div>Laden...</div>}{!isLoading && !error && (<table><thead><tr>{selectedFields.map((f) => (<th key={f.key}>{f.label}</th>))}</tr></thead><tbody>{rows.map((row: Record<string, unknown>) => (<tr key={row.id as string}>{selectedFields.map((f) => (<td key={f.key}>{String(row[f.key] ?? "")}</td>))}</tr>))}</tbody></table>)}</div>);}
Der Datenfluss:
- Benutzer schaltet Checkboxen um →
selectedKeysState wird aktualisiert selectedFieldswird peruseMemoaus der Registry gefiltertuseQueryfeuert mit den sortierten Feld-Keys im Cache KeybuildOrderQueryproduziert den GraphQL-String aus den ausgewählten Feldern- Die Antwort wird geflattened (verschachtelte Pfade → flache Zeilen-Keys) für die Tabelle
- Die Tabelle rendert nur die ausgewählten Spalten
Dynamische Mutations
Bisher liest die Pipeline nur Daten. Aber wenn der Benutzer den Status einer Bestellung in einer dynamischen Tabelle sehen kann, ist die nächste Frage: Kann er ihn auch ändern?
Dieselbe Architektur, die Queries baut, kann auch Mutations bauen. Die Ergänzungen sind: herausfinden welche Mutation aufgerufen werden soll, wissen welche Felder beschreibbar sind, und den Mutation-String + Variablen aus den Änderungen des Benutzers bauen.
Mutations per Namenskonvention erkennen
Die meisten GraphQL-APIs folgen einer vorhersehbaren Namenskonvention:
| Query-Typ | Mutation | Input-Typ |
|---|---|---|
Order | updateOrder | UpdateOrderInput |
Customer | updateCustomer | UpdateCustomerInput |
Product | updateProduct | UpdateProductInput |
Das lässt sich mit einem Helper formalisieren und dann gegen das Schema validieren:
tstype MutationOperation = "update" | "create" | "delete";function getMutationName(typeName: string,operation: MutationOperation,): string {return `${operation}${typeName}`;}function getInputTypeName(typeName: string,operation: MutationOperation,): string {return `${operation.charAt(0).toUpperCase() + operation.slice(1)}${typeName}Input`;}// getMutationName("Order", "update") → "updateOrder"// getInputTypeName("Order", "update") → "UpdateOrderInput"
Um zu bestätigen, dass die Mutation tatsächlich existiert, wird der Mutation-Root-Typ introspektiert:
tsasync function discoverMutations(typeName: string) {const mutationRoot = await introspectType("Mutation");const available = new Map<MutationOperation, string>();for (const op of ["update", "create", "delete"] as const) {const name = getMutationName(typeName, op);if (mutationRoot.fields.some((f: IntrospectionField) => f.name === name)) {available.set(op, name);}}return available;}// discoverMutations("Order")// → Map { "update" → "updateOrder", "delete" → "deleteOrder" }// (kein "createOrder" falls die API es nicht exponiert)
Zur Build-Zeit validieren
Mutation-Discovery ins generate:fields-Script aufnehmen. Wenn die Konvention
bricht (jemand nennt es modifyOrder statt updateOrder), fällt das bei der
Generierung auf, nicht wenn ein Benutzer auf Speichern klickt.
Input-Typ introspektieren
Die Update-Mutation nimmt typischerweise einen Input-Typ entgegen, der die Query-Felder spiegelt, abzüglich berechneter und schreibgeschützter Felder:
tsasync function buildInputRegistry(typeName: string,): Promise<FieldDefinition[]> {const inputTypeName = getInputTypeName(typeName, "update");const inputType = await introspectType(inputTypeName);if (!inputType) {throw new Error(`Input type ${inputTypeName} not found in schema`);}return buildFieldRegistry(inputType);}
Das Ergebnis sagt genau, welche Felder beschreibbar sind. Wenn Order 12 Query-Felder hat, aber UpdateOrderInput nur 6, sind die anderen 6 schreibgeschützt (berechnete Summen, Timestamps etc.). Die Edit-UI rendert nur Eingabefelder für Felder, die in beiden Registries vorkommen, der Query-Registry und der Input-Registry:
tsconst queryFields = ORDER_FIELDS;const inputFields = await buildInputRegistry("Order");const editableKeys = new Set(inputFields.map((f) => f.key));const editableQueryFields = queryFields.filter((f) => editableKeys.has(f.key));// Nur diese Felder bekommen eine "Bearbeiten"-Funktion in der UI
Der Mutation Builder
Spiegelt buildOrderQuery, verpackt die Feldauswahl aber in einer Mutation mit einer $input-Variable. Die Rückgabe-Selektion nutzt die Query-Felder wieder, damit der Cache mit frischen Daten aktualisiert werden kann:
tsfunction buildUpdateMutation(typeName: string,inputTypeName: string,returnFields: FieldDefinition[],): string {const mutationName = getMutationName(typeName, "update");// Dieselbe Gruppierungslogik wie beim Query Builder für Rueckgabefelderconst paths = ["id", ...returnFields.map((f) => f.graphqlPath)];const roots: string[] = [];const nested = new Map<string, string[]>();for (const path of paths) {const dot = path.indexOf(".");if (dot === -1) {roots.push(path);} else {const parent = path.slice(0, dot);const child = path.slice(dot + 1);if (!nested.has(parent)) nested.set(parent, []);nested.get(parent)!.push(child);}}const selections = [...roots,...[...nested.entries()].map(([parent, children]) => `${parent} { ${children.join(" ")} }`,),];return `mutation ${mutationName.charAt(0).toUpperCase() + mutationName.slice(1)}($id: ID!, $input: ${inputTypeName}!) {${mutationName}(id: $id, input: $input) {${selections.join("\n ")}}}`;}
Für ein Order-Update mit ["status", "shippingAddressCity"] ausgewählt, produziert das:
graphqlmutation UpdateOrder($id: ID!, $input: UpdateOrderInput!) {updateOrder(id: $id, input: $input) {idstatusshippingAddress {city}}}
Input-Variablen bauen
Die Query-Pipeline flattened verschachtelte Antworten in flache Keys (shippingAddress.city → shippingAddressCity). Die Mutation-Pipeline braucht das Gegenteil, editierte Werte zurück in die verschachtelte Struktur unflatten, die die API erwartet:
tsfunction unflattenInput(flatData: Record<string, unknown>,fields: FieldDefinition[],): Record<string, unknown> {const result: Record<string, unknown> = {};for (const field of fields) {if (!(field.key in flatData)) continue;const dot = field.graphqlPath.indexOf(".");if (dot === -1) {// Top-Level-Feldresult[field.graphqlPath] = flatData[field.key];} else {// Verschachteltes Feld - Objekt rekonstruierenconst parent = field.graphqlPath.slice(0, dot);const child = field.graphqlPath.slice(dot + 1);if (!result[parent]) result[parent] = {};(result[parent] as Record<string, unknown>)[child] = flatData[field.key];}}return result;}// unflattenInput(// { status: "shipped", shippingAddressCity: "Berlin" },// selectedFields,// )// → { status: "shipped", shippingAddress: { city: "Berlin" } }
Input vor dem Senden validieren
Denselben Zod-Ansatz wie auf der Query-Seite wiederverwenden, aber diesmal die Eingabe vor dem Senden validieren statt die Antwort nach dem Empfang:
tsfunction buildInputSchema(fields: FieldDefinition[]) {const shape: Record<string, z.ZodTypeAny> = {};for (const field of fields) {shape[field.key] = FIELD_VALIDATORS[field.type];}return z.object(shape);}// Validieren was der Benutzer eingegeben hat, bevor die Mutation-Variablen gebaut werdenconst inputSchema = buildInputSchema(editableFields);const parsed = inputSchema.parse(dirtyValues); // wirft bei ungueltigem Inputconst variables = unflattenInput(parsed, editableFields);
Das fängt Typ-Mismatches ab (Benutzer hat "abc" in ein Zahlenfeld getippt), bevor der Request den Client verlässt.
Alles verdrahten
Mit TanStack Querys useMutation sieht die Integration so aus:
tsfunction useUpdateOrder(fields: FieldDefinition[]) {const queryClient = useQueryClient();const inputTypeName = getInputTypeName("Order", "update");const editableFields = fields.filter((f) => editableKeys.has(f.key));return useMutation({mutationFn: async ({id,values,}: {id: string;values: Record<string, unknown>;}) => {// 1. Validierenconst parsed = buildInputSchema(editableFields).parse(values);// 2. Unflattenconst input = unflattenInput(parsed, editableFields);// 3. Bauen & sendenconst res = await fetch("/graphql", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({query: buildUpdateMutation("Order", inputTypeName, fields),variables: { id, input },}),});return res.json();},onSuccess: () => {// Query-Cache invalidieren, damit die Tabelle mit aktualisierten Daten neu fetchtqueryClient.invalidateQueries({ queryKey: ["orders"] });},});}
Die Komponente trackt, welche Felder der Benutzer geändert hat (Dirty State), und ruft die Mutation nur mit diesen Werten auf:
tsxconst updateOrder = useUpdateOrder(selectedFields);// Beim Speichern:updateOrder.mutate({id: row.id,values: dirtyValues, // nur die Felder, die der Benutzer tatsaechlich editiert hat});
Die komplette Pipeline: Lesen und Schreiben
Die Lese- und Schreib-Pfade sind symmetrisch:
Lesen: Registry → Query Builder → fetch → flatten → validieren → Tabelle
Schreiben: Dirty Fields → validieren → unflatten → Mutation Builder → fetch → Cache invalidieren
Beide Pfade teilen dieselbe FieldDefinition[], dieselben Namenskonventionen und denselben Validierungsansatz. Die Registry ist die Single Source of Truth für beide Richtungen.
Create und Delete folgen demselben Muster
Dieser Abschnitt fokussiert sich auf update als repräsentativen Fall.
create funktioniert identisch: CreateOrderInput introspektieren, mit
buildCreateMutation bauen, unflattenInput verwenden. Delete ist noch
einfacher: es braucht nur die id, keine Input-Introspection nötig. Die
Namenskonvention (createOrder, deleteOrder) und die Discovery per
discoverMutations decken alle drei ab.
Einfach alles fetchen?
Bevor man sich auf dynamische Queries festlegt, sollte man fragen, ob man sie wirklich braucht.
Wenn das Objekt ein flacher oder flach verschachtelter Typ mit unter ~30 skalaren Feldern ist und keine großen Text-/Binär-Blobs hat, ist eine statische Query, die alles fetcht, einfacher:
ts// Statisch, voll generiert, voll typisiertconst { data } = useGetOrdersQuery();// Checkboxen steuern nur, welche Spalten gerendert werden - nicht was gefetcht wirdconst visibleColumns = ORDER_FIELDS.filter((f) => selectedKeys.has(f.key));
Das gibt volle Codegen-Typen, null Query-Building-Code und einfacheres Caching. Die Checkboxen werden ein reines UI-Anliegen.
Wenn "alles" undefiniert ist
Dieser Ansatz bricht zusammen, sobald das Schema rekursive oder zirkuläre Typen hat:
graphqltype User {id: ID!name: String!friends: [User!]! # User → Usermanager: User # User → User nochmaldepartment: Department!}type Department {id: ID!name: String!members: [User!]! # Department → User → Department → ...}
User.friends gibt [User] zurück. Jeder Freund hat eigene friends. Es gibt kein "alle Felder", der Graph ist unendlich. Eine naive "alles fetchen"-Query würde rekursieren, bis der Server sein Tiefenlimit erreicht und einen Fehler wirft.
Das ist eine fundamentale Eigenschaft von GraphQL: Das G steht für Graph, und Graphen haben Zyklen. Jeder Typ, der sich selbst referenziert (direkt oder über andere Typen), macht die "alles fetchen"-Abkürzung unmöglich.
Rekursive Typen brauchen explizite Tiefe
Man kann sich nicht per Introspection aus diesem Problem herausarbeiten. Das
Schema sagt, dass User.friends [User] zurückgibt, aber es sagt nicht, wie
tief man gehen soll. Das ist eine Produktentscheidung, keine
Schema-Entscheidung. Dynamische Queries mit expliziten Tiefenlimits (der
Ansatz in diesem Artikel) sind die einzige saubere Lösung für rekursive Typen.
Dynamische Queries verdienen ihre Komplexität, wenn:
- Der Typ-Graph Zyklen hat: rekursive oder gegenseitig rekursive Typen, bei denen "alle Felder" undefiniert ist
- Objekte 50+ Felder haben oder große Text-/Binär-Felder enthalten
- Jedes Feld teure Resolver-Arbeit auslöst (separate DB-Joins, externe API-Calls)
- Bandbreite begrenzt ist (Mobil, getaktete Verbindungen)
- Feld-Level-Autorisierung bedeutet, dass verschiedene Benutzer verschiedene Schemas sehen
- Die API pro Feld oder pro Resolver-Aufruf abrechnet
Wenn die Typen flach sind, nichts davon zutrifft und jede Query zur Build-Zeit definiert werden kann, fetche alles und filtere die Anzeige. Für alles andere braucht man die dynamische Pipeline.
Absicherung für Produktion
Query-Komplexitäts-Limits
Lass Benutzer nicht 200 Felder auswählen:
tsconst MAX_FIELDS = 20;const selectedFields = fields.slice(0, MAX_FIELDS);if (fields.length > MAX_FIELDS) {console.warn(`Feldauswahl auf ${MAX_FIELDS} begrenzt`);}
Feld-Level-Autorisierung
Manche Felder sind nicht für alle Benutzer verfügbar. Filtere die Registry, bevor du Checkboxen renderst:
tsconst visibleFields = ORDER_FIELDS.filter((f) =>userPermissions.allowedFields.includes(f.key),);
So tauchen unautorisierte Felder nie in der UI auf und landen nie in der Query.
Fehlerbehandlung
Umschließe die Datentabelle mit einer Error Boundary. GraphQL-Teilfehler (manche Felder lösen auf, manche nicht) sind bei dynamischen Queries häufig, die UI sollte sie elegant behandeln, statt zu crashen.
Testing
Drei Test-Ebenen machen diese Architektur zuverlässig:
-
Query Builder Snapshots: prüfe, dass eine gegebene Feldauswahl den erwarteten GraphQL-String produziert. Diese fangen Regressionen ab, wenn man den Builder refactored.
-
Schema-Validierungstests: wenn man Zugriff auf das GraphQL-Schema hat (via Introspection oder lokale Schema-Datei), validiere, dass jedes Feld in der Registry im Schema existiert. Das fängt Drift zwischen Registry und API ab.
-
Integrationstests: sende echte Queries an eine Test-API (oder Mock-Server) und validiere die Antwort durch das Zod-Schema. Das testet die gesamte Pipeline von Auswahl bis geparsten Ergebnis.
ts// Beispiel: Query Builder Snapshot-Testtest("baut Query mit verschachtelten Feldern", () => {const fields = ORDER_FIELDS.filter((f) =>["orderNumber", "shippingAddressCity", "shippingAddressCountry"].includes(f.key,),);expect(buildOrderQuery(fields)).toMatchInlineSnapshot(`"query GetOrders($filter: OrderFilter) {orders(filter: $filter) {idorderNumbershippingAddress { city country }}}"`);});
Nutze es: @saschb2b/gql-drift
Alles, was in diesem Artikel beschrieben wird — die Introspection-Schicht, Field Registries, Query-/Mutation-Builder, Flatten/Unflatten, Zod-Validierung und React-Integration — ist als Open-Source npm-Paket verfügbar.
bashpnpm add @saschb2b/gql-drift
Das CLI generiert typisierte Field Registries aus jedem GraphQL-Endpoint oder lokaler Schema-Datei:
bashnpx gql-drift generate --endpoint http://localhost:4000/graphql --types Order,Customernpx gql-drift generate --schema ./schema.graphql --types '*' --exclude '*Connection,*Edge'
Wildcard-Type-Discovery (types: "*") entdeckt automatisch alle Objekt-Typen aus dem Schema, mit Glob-Style --exclude-Patterns zum Filtern von Relay-Typen oder anderem Rauschen.
Der generierte Code folgt dem TanStack Query v5 queryOptions-Pattern und produziert Options-Factories, die man in Standard-Hooks spreaden kann:
tsximport { useQuery, useMutation } from "@tanstack/react-query";import { orderQueryOptions, updateOrderMutation } from "./generated/order";const { data } = useQuery({ ...orderQueryOptions({ config }) });const { mutate } = useMutation({ ...updateOrderMutation({ config }) });
Das Paket unterstützt auch benutzerdefinierte GraphQL-Clients (urql, Apollo, graphql-request) über eine fetcher-Option und funktioniert ohne React als reine TypeScript-Bibliothek.
Für die vollständige API siehe die Paket-Dokumentation.
Quellen & Weiterführende Links
- GraphQL-Spezifikation: Feldauswahl
Die offizielle GraphQL-Spezifikation zu Feldauswahl und wie Selection Sets auf Protokollebene funktionieren.
- TanStack Query
Die populärste clientseitige Data-Fetching-Bibliothek für React, übernimmt Caching, Background-Refetching, Pagination und Query-Key-Management.
- Zod
TypeScript-first Schema-Validierungsbibliothek, ideal für Runtime-Validierung dynamischer API-Antworten.
- TypeScript Utility Types: Pick, Partial, Omit
TypeScripts eingebaute Utility-Typen zum Konstruieren von partiellen und gepickten Typen aus bestehenden Interfaces.
- GraphQL Code Generator
Das Standard-Codegen-Tool für statische GraphQL-Queries, nutze es für alles, was generiert werden KANN, und dynamische Queries für den Rest.
- @saschb2b/gql-drift
Open-Source npm-Paket, das die komplette dynamische GraphQL-Pipeline aus diesem Artikel implementiert: Introspection, Field Registries, Query-/Mutation-Builder, Zod-Validierung und React-Integration.
