2026

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.

S
Sascha Becker
Author

25 Min. Lesezeit

Dynamische GraphQL-Queries zur Laufzeit

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.

Die verlockende Abkürzung

Der erste Instinkt ist String-Konkatenation:

ts
function 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. data ist any. 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:

SchichtVerantwortung
IntrospectionSchema lesen: Typen, Felder, Verschachtelung, Skalartypen und verfügbare Mutations entdecken
Field RegistryStrukturierte Ausgabe: mappt introspektierte Felder auf UI-Labels, GraphQL-Pfade und Formatierungstypen
Query BuilderReine Funktion: ausgewählte Felder rein, gültiger GraphQL-Query-String raus
Mutation BuilderReine Funktion: geänderte Felder rein, gültiger GraphQL-Mutation-String raus
Runtime-ValidierungZod-Schema: validiert API-Antworten und Benutzereingaben vor der Mutation
Data LayerTransport: Fetch-Wrapper oder TanStack Query mit korrekten Cache Keys
UI LayerCheckboxen 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?"

ts
const INTROSPECTION_QUERY = `
query IntrospectType($typeName: String!) {
__type(name: $typeName) {
name
fields {
name
type {
name
kind
ofType {
name
kind
ofType {
name
kind
}
}
}
}
}
}
`;
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 bekommen
function unwrapType(t: IntrospectionType): IntrospectionType {
while (t.kind === "NON_NULL" || t.kind === "LIST") {
t = t.ofType!;
}
return t;
}
// GraphQL-Skalarnamen auf unser vereinfachtes Typsystem mappen
const 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 eingeschlossen
const 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-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.ts
import { 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.ts
import 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.

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:

ts
const 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:

ts
const 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.

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.

ts
function buildOrderQuery(fields: FieldDefinition[]): string {
// Immer id einschließen
const 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 aufbauen
const 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:

graphql
query GetOrders($filter: OrderFilter) {
orders(filter: $filter) {
id
orderNumber
customerName
shippingAddress {
city
country
}
}
}
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
ts
type 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:

ts
type 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

Baue ein Zod-Schema dynamisch aus derselben Field Registry:

ts
import { 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 validieren
const 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):

ts
const 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:

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

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 }}>
<input
type="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:

  1. Benutzer schaltet Checkboxen um → selectedKeys State wird aktualisiert
  2. selectedFields wird per useMemo aus der Registry gefiltert
  3. useQuery feuert mit den sortierten Feld-Keys im Cache Key
  4. buildOrderQuery produziert den GraphQL-String aus den ausgewählten Feldern
  5. Die Antwort wird geflattened (verschachtelte Pfade → flache Zeilen-Keys) für die Tabelle
  6. 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-TypMutationInput-Typ
OrderupdateOrderUpdateOrderInput
CustomerupdateCustomerUpdateCustomerInput
ProductupdateProductUpdateProductInput

Das lässt sich mit einem Helper formalisieren und dann gegen das Schema validieren:

ts
type 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:

ts
async 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)
Input-Typ introspektieren

Die Update-Mutation nimmt typischerweise einen Input-Typ entgegen, der die Query-Felder spiegelt, abzüglich berechneter und schreibgeschützter Felder:

ts
async 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:

ts
const 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:

ts
function buildUpdateMutation(
typeName: string,
inputTypeName: string,
returnFields: FieldDefinition[],
): string {
const mutationName = getMutationName(typeName, "update");
// Dieselbe Gruppierungslogik wie beim Query Builder für Rueckgabefelder
const 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:

graphql
mutation UpdateOrder($id: ID!, $input: UpdateOrderInput!) {
updateOrder(id: $id, input: $input) {
id
status
shippingAddress {
city
}
}
}
Input-Variablen bauen

Die Query-Pipeline flattened verschachtelte Antworten in flache Keys (shippingAddress.cityshippingAddressCity). Die Mutation-Pipeline braucht das Gegenteil, editierte Werte zurück in die verschachtelte Struktur unflatten, die die API erwartet:

ts
function 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-Feld
result[field.graphqlPath] = flatData[field.key];
} else {
// Verschachteltes Feld - Objekt rekonstruieren
const 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:

ts
function 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 werden
const inputSchema = buildInputSchema(editableFields);
const parsed = inputSchema.parse(dirtyValues); // wirft bei ungueltigem Input
const 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:

ts
function 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. Validieren
const parsed = buildInputSchema(editableFields).parse(values);
// 2. Unflatten
const input = unflattenInput(parsed, editableFields);
// 3. Bauen & senden
const 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 fetcht
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
});
}

Die Komponente trackt, welche Felder der Benutzer geändert hat (Dirty State), und ruft die Mutation nur mit diesen Werten auf:

tsx
const updateOrder = useUpdateOrder(selectedFields);
// Beim Speichern:
updateOrder.mutate({
id: row.id,
values: dirtyValues, // nur die Felder, die der Benutzer tatsaechlich editiert hat
});

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 typisiert
const { data } = useGetOrdersQuery();
// Checkboxen steuern nur, welche Spalten gerendert werden - nicht was gefetcht wird
const 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:

graphql
type User {
id: ID!
name: String!
friends: [User!]! # User → User
manager: User # User → User nochmal
department: 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.

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:

ts
const 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:

ts
const 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:

  1. Query Builder Snapshots: prüfe, dass eine gegebene Feldauswahl den erwarteten GraphQL-String produziert. Diese fangen Regressionen ab, wenn man den Builder refactored.

  2. 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.

  3. 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-Test
test("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) {
id
orderNumber
shippingAddress { 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.

bash
pnpm add @saschb2b/gql-drift

Das CLI generiert typisierte Field Registries aus jedem GraphQL-Endpoint oder lokaler Schema-Datei:

bash
npx gql-drift generate --endpoint http://localhost:4000/graphql --types Order,Customer
npx 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:

tsx
import { 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.


S
Geschrieben von
Sascha Becker
Weitere Artikel