Skip to content

Building Type-Safe GraphQL APIs with Code Generation

A practical guide to end-to-end type safety in GraphQL with schema-first code generation: typed resolvers, client query types and no runtime mismatches.

3 min read
A pipeline diagram showing GraphQL schema flowing through code generation into typed resolvers and client queries

The Type Gap in GraphQL Applications

GraphQL promises a typed API contract, but most implementations have a gap: the schema defines types in SDL, the resolvers use untyped JavaScript objects, and the client manually types response shapes. Any mismatch between these three layers creates runtime errors that the type system should have caught.

Code generation closes this gap by deriving TypeScript types from the GraphQL schema automatically—for both server resolvers and 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!]
}

Generating Resolver Types

Code generation transforms the schema into TypeScript interfaces that resolvers must satisfy. The compiler enforces that every resolver returns the correct shape—no more hoping your resolver matches the schema.

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 Configuration

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"

Client-Side Type Safety

The same schema generates typed hooks for the client. Query results, mutation inputs, and variables are all type-checked against the schema—the frontend and backend are guaranteed to agree on shapes.

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

Handling Schema Evolution

Types change as the product evolves. Code generation turns schema changes into compiler errors, making every breaking change immediately visible.

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

Key Takeaways

GraphQL code generation eliminates the type gap between schema, resolvers, and client queries. The schema becomes the single source of truth, and every TypeScript type is derived from it automatically. Resolver return types are enforced—missing fields or wrong types are compiler errors, not runtime bugs.

Run code generation in CI so that every schema change produces fresh types. When a field is removed or its type changes, the TypeScript compiler immediately identifies every affected resolver and client query. This turns schema evolution from a manual audit into an automated verification.

The investment is a codegen configuration file and a build step. The return is end-to-end type safety across every layer of the GraphQL stack, eliminating an entire class of bugs that would otherwise surface only in production.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX