Zum Inhalt springen

Typsichere GraphQL-APIs mit Code-Generierung erstellen

Praktischer Leitfaden für durchgängige Typsicherheit in GraphQL mit Schema-first-Codegenerierung: typisierte Resolver, Query-Typen, keine Typkonflikte.

3 Min. Lesezeit
Ein Pipeline-Diagramm, das zeigt, wie das GraphQL-Schema durch Code-Generierung in typisierte Resolver und Client-Queries fließt

Die Typ-Lücke in GraphQL-Anwendungen

GraphQL verspricht einen typisierten API-Vertrag, doch die meisten Implementierungen haben eine Lücke: Das Schema definiert Typen in SDL, die Resolver verwenden untypisierte JavaScript-Objekte, und der Client typisiert die Antwortformen manuell. Jede Abweichung zwischen diesen drei Schichten erzeugt Laufzeitfehler, die das Typsystem hätte abfangen müssen.

Code-Generierung schließt diese Lücke, indem sie TypeScript-Typen automatisch aus dem GraphQL-Schema ableitet – sowohl für Server-Resolver als auch für Client-Queries.

Schema-First-Design

graphqlgraphql
# schema.graphql — the single source of truth
type User {
  id: ID!
  email: String!
  name: String!
  role: UserRole!
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
  createdAt: DateTime!
}
 
enum UserRole {
  ADMIN
  EDITOR
  VIEWER
}
 
type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  tags: [String!]!
  publishedAt: DateTime
  status: PostStatus!
}
 
enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}
 
type Query {
  user(id: ID!): User
  users(role: UserRole, limit: Int = 20): [User!]!
  post(id: ID!): Post
  posts(status: PostStatus, limit: Int = 20): [Post!]!
}
 
type Mutation {
  createUser(input: CreateUserInput!): User!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  publishPost(id: ID!): Post!
}
 
input CreateUserInput {
  email: String!
  name: String!
  role: UserRole!
}
 
input UpdatePostInput {
  title: String
  content: String
  tags: [String!]
}

Resolver-Typen generieren

Die Code-Generierung verwandelt das Schema in TypeScript-Interfaces, die Resolver erfüllen müssen. Der Compiler erzwingt, dass jeder Resolver die korrekte Form zurückgibt – kein Hoffen mehr, dass der Resolver zum Schema passt.

tstypescript
// ❌ Untyped resolvers — schema/resolver mismatch is invisible
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      const user = await db.users.findById(id);
      return user; // Does this match the User type? Who knows.
    },
  },
};
 
// ✅ Generated types enforce schema compliance
// Generated by graphql-codegen from schema.graphql
import type { Resolvers } from "./generated/resolvers-types";
 
const resolvers: Resolvers = {
  Query: {
    user: async (_parent, { id }, context) => {
      const user = await context.db.users.findById(id);
      if (!user) return null;
 
      return {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role,      // Must be UserRole enum
        createdAt: user.createdAt,
        // TypeScript error if any required field is missing
      };
    },
 
    users: async (_parent, { role, limit }, context) => {
      return context.db.users.findMany({
        where: role ? { role } : undefined,
        take: limit ?? 20,
      });
    },
  },
 
  User: {
    posts: async (parent, { limit, offset }, context) => {
      return context.db.posts.findMany({
        where: { authorId: parent.id },
        take: limit ?? 10,
        skip: offset ?? 0,
      });
    },
  },
 
  Mutation: {
    createUser: async (_parent, { input }, context) => {
      return context.db.users.create({ data: input });
    },
 
    publishPost: async (_parent, { id }, context) => {
      return context.db.posts.update({
        where: { id },
        data: { status: "PUBLISHED", publishedAt: new Date() },
      });
    },
  },
};

Codegen-Konfiguration

ymlyaml
# codegen.yml
schema: "./schema.graphql"
generates:
  # Server-side resolver types
  ./src/generated/resolvers-types.ts:
    plugins:
      - typescript
      - typescript-resolvers
    config:
      contextType: "../context#GraphQLContext"
      mappers:
        User: "../models#UserModel"
        Post: "../models#PostModel"
      scalars:
        DateTime: "Date"
 
  # Client-side operation types
  ./src/generated/operations.ts:
    documents: "./src/**/*.graphql"
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo
    config:
      withHooks: true
      scalars:
        DateTime: "string"

Typsicherheit auf der Client-Seite

Dasselbe Schema generiert typisierte Hooks für den Client. Query-Ergebnisse, Mutation-Inputs und Variablen werden alle gegen das Schema geprüft – Frontend und Backend sind garantiert in ihren Formen abgestimmt.

tstypescript
// src/queries/user.graphql
// query GetUser($id: ID!) {
//   user(id: $id) {
//     id
//     email
//     name
//     role
//     posts(limit: 5) {
//       id
//       title
//       status
//     }
//   }
// }
 
// Generated hook — fully typed
import { useGetUserQuery } from "../generated/operations";
 
function UserProfile({ userId }: { userId: string }) {
  const { data, loading, error } = useGetUserQuery({
    variables: { id: userId }, // Type-checked
  });
 
  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  if (!data?.user) return <NotFound />;
 
  const { user } = data;
 
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <span>{user.role}</span> {/* Typed as UserRole enum */}
      <ul>
        {user.posts.map((post) => (
          <li key={post.id}>
            {post.title} — {post.status}
          </li>
        ))}
      </ul>
    </div>
  );
}

Umgang mit Schema-Evolution

Typen ändern sich, wenn sich das Produkt weiterentwickelt. Die Code-Generierung verwandelt Schema-Änderungen in Compiler-Fehler und macht jede Breaking Change sofort sichtbar.

tstypescript
interface SchemaChange {
  type: "field-added" | "field-removed" | "type-changed" | "field-deprecated";
  path: string;
  breaking: boolean;
}
 
function analyzeSchemaChanges(
  oldSchema: string,
  newSchema: string
): SchemaChange[] {
  const changes: SchemaChange[] = [];
 
  // Adding a nullable field — non-breaking
  // Adding a required field — breaking for mutations
  // Removing a field — breaking for queries using it
  // Changing a field type — breaking
 
  // After codegen runs, TypeScript surfaces the impact:
  // - Removed field? Every resolver and query referencing it errors
  // - Type changed? Every consumer with wrong type errors
  // - New required input? Every mutation call missing it errors
 
  return changes;
}
 
// CI pipeline: regenerate types on schema changes
// 1. Developer modifies schema.graphql
// 2. CI runs graphql-codegen
// 3. TypeScript compilation checks all resolvers and client queries
// 4. Build fails if any consumer doesn't match the new schema
// 5. Developer fixes consumers before merging

Wichtige Erkenntnisse

Die GraphQL-Code-Generierung beseitigt die Typ-Lücke zwischen Schema, Resolvern und Client-Queries. Das Schema wird zur einzigen Quelle der Wahrheit, und jeder TypeScript-Typ wird automatisch daraus abgeleitet. Rückgabetypen der Resolver werden erzwungen – fehlende Felder oder falsche Typen sind Compiler-Fehler, keine Laufzeitbugs.

Führe die Code-Generierung in CI aus, damit jede Schema-Änderung frische Typen erzeugt. Wenn ein Feld entfernt oder sein Typ geändert wird, identifiziert der TypeScript-Compiler sofort jeden betroffenen Resolver und jede Client-Query. Das macht Schema-Evolution von einem manuellen Audit zu einer automatisierten Prüfung.

Die Investition ist eine Codegen-Konfigurationsdatei und ein Build-Schritt. Der Ertrag ist durchgängige Typsicherheit über jede Schicht des GraphQL-Stacks hinweg – eine ganze Klasse von Bugs, die sonst erst in der Produktion auftauchen würde, wird eliminiert.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX