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.

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
# 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.
// ❌ 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
# 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.
// 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.
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 mergingKey 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.


