DEV Community

arenasbob2024-cell
arenasbob2024-cell

Posted on • Originally published at viadreams.cc

GraphQL to TypeScript: Automated Code Generation Guide

If you write GraphQL queries by hand and then define TypeScript types separately, you are maintaining two sources of truth that will inevitably drift apart. A renamed field in the schema, a new nullable return type, a deprecated argument -- any of these can silently break your frontend at runtime while the compiler stays quiet.

@graphql-codegen eliminates this problem entirely. It reads your GraphQL schema and operations, then generates TypeScript types, typed hooks, and even full SDK clients -- all at build time.

This guide walks through the full setup from zero to production-ready code generation.

Why Type Safety Matters for GraphQL

GraphQL already gives you a typed schema. The problem is that this type information lives on the server. Your TypeScript frontend has no compile-time knowledge of what query GetUser actually returns unless you manually write the corresponding interfaces.

Manual typing has three failure modes:

  1. Types go stale. The backend adds a field or changes a type. Your manually written interface stays the same. The bug shows up at runtime.
  2. Types are incomplete. Developers skip optional fields or use any to save time. You lose the benefits of TypeScript.
  3. Types are duplicated. The same query is used in three components, each with its own hand-rolled type definition.

Automated code generation solves all three. Types are derived directly from the schema and your .graphql files, generated fresh on every build, and shared across the project.

Setting Up @graphql-codegen

Install Dependencies

npm install -D @graphql-codegen/cli \
  @graphql-codegen/typescript \
  @graphql-codegen/typescript-operations \
  @graphql-codegen/typescript-react-query
Enter fullscreen mode Exit fullscreen mode

If you use Apollo Client instead of React Query, swap the last package:

npm install -D @graphql-codegen/typescript-react-apollo
Enter fullscreen mode Exit fullscreen mode

Create the Config File

Create codegen.ts in your project root:

import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'http://localhost:4000/graphql',
  documents: ['src/**/*.graphql'],
  generates: {
    './src/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-react-query',
      ],
      config: {
        reactQueryVersion: 5,
        fetcher: {
          func: './fetcher#useFetchData',
          isReactHook: true,
        },
        enumsAsTypes: true,
        skipTypename: false,
        dedupeFragments: true,
      },
    },
  },
  hooks: {
    afterAllFileWrite: ['prettier --write'],
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

You can also use codegen.yml if you prefer YAML:

schema: "http://localhost:4000/graphql"
documents: "src/**/*.graphql"
generates:
  ./src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-query
    config:
      reactQueryVersion: 5
      enumsAsTypes: true
      skipTypename: false
Enter fullscreen mode Exit fullscreen mode

Add a Script

{
  "scripts": {
    "codegen": "graphql-codegen --config codegen.ts",
    "codegen:watch": "graphql-codegen --config codegen.ts --watch"
  }
}
Enter fullscreen mode Exit fullscreen mode

Run npm run codegen and the generator will introspect your schema, scan your .graphql files, and output a single graphql.ts file with everything typed.

Writing GraphQL Operations

Create your queries and mutations as .graphql files. This keeps them separate from component logic and makes them easy for the codegen to discover.

# src/graphql/queries/getUser.graphql
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
    role
    avatar
    posts {
      id
      title
      publishedAt
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
# src/graphql/mutations/updateUser.graphql
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
  updateUser(id: $id, input: $input) {
    id
    name
    email
    role
  }
}
Enter fullscreen mode Exit fullscreen mode

What Gets Generated

After running npm run codegen, the generated file contains three layers of types.

1. Schema Types

These map directly to your GraphQL schema:

// Auto-generated -- do not edit
export type User = {
  __typename?: 'User';
  id: string;
  name: string;
  email: string;
  role: UserRole;
  avatar?: string | null;
  posts: Array<Post>;
};

export type Post = {
  __typename?: 'Post';
  id: string;
  title: string;
  publishedAt: string;
};

export type UserRole = 'ADMIN' | 'EDITOR' | 'VIEWER';

export type UpdateUserInput = {
  name?: string | null;
  email?: string | null;
  role?: UserRole | null;
};
Enter fullscreen mode Exit fullscreen mode

2. Operation Types

These are scoped to each query or mutation you wrote:

export type GetUserQueryVariables = Exact<{
  id: Scalars['ID']['input'];
}>;

export type GetUserQuery = {
  __typename?: 'Query';
  user: {
    __typename?: 'User';
    id: string;
    name: string;
    email: string;
    role: UserRole;
    avatar?: string | null;
    posts: Array<{
      __typename?: 'Post';
      id: string;
      title: string;
      publishedAt: string;
    }>;
  };
};
Enter fullscreen mode Exit fullscreen mode

3. Typed Hooks

With the React Query plugin, you get ready-to-use hooks:

export const useGetUserQuery = <TData = GetUserQuery, TError = unknown>(
  variables: GetUserQueryVariables,
  options?: Omit<UseQueryOptions<GetUserQuery, TError, TData>, 'queryKey'>
) =>
  useQuery<GetUserQuery, TError, TData>({
    queryKey: ['GetUser', variables],
    queryFn: useFetchData<GetUserQuery, GetUserQueryVariables>(
      GetUserDocument
    ).bind(null, variables),
    ...options,
  });
Enter fullscreen mode Exit fullscreen mode

Using Generated Code in Components

With the generated hooks, your components become clean and fully typed:

import { useGetUserQuery } from '@/generated/graphql';

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useGetUserQuery({ id: userId });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading user</div>;

  const user = data?.user;
  if (!user) return <div>User not found</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <span>{user.role}</span>
      <h2>Posts ({user.posts.length})</h2>
      <ul>
        {user.posts.map((post) => (
          <li key={post.id}>
            {post.title} - {post.publishedAt}
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Everything is typed. user.name is a string. user.avatar is string | null. user.posts is an array of Post. If the backend renames publishedAt to createdAt, your next codegen run will produce a compile error exactly where you reference the old field.

Mutations work the same way:

import { useUpdateUserMutation } from '@/generated/graphql';

function EditUserForm({ userId }: { userId: string }) {
  const { mutate, isPending } = useUpdateUserMutation();

  const handleSubmit = (formData: FormData) => {
    mutate({
      id: userId,
      input: {
        name: formData.get('name') as string,
        email: formData.get('email') as string,
      },
    });
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); handleSubmit(new FormData(e.currentTarget)); }}>
      <input name="name" placeholder="Name" />
      <input name="email" placeholder="Email" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Saving...' : 'Save'}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

The input parameter is typed as UpdateUserInput, so passing an unknown field like { username: '...' } will be caught at compile time.

Document Transforms

The @graphql-codegen pipeline supports document transforms that modify your operations before code generation. This is useful for injecting __typename fields, adding directives, or stripping client-only fields.

A common use case is adding the __typename field to every selection set for cache normalization:

// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';
import { addTypenameSelectionDocumentTransform } from '@graphql-codegen/client-preset';

const config: CodegenConfig = {
  schema: 'http://localhost:4000/graphql',
  documents: ['src/**/*.graphql'],
  generates: {
    './src/generated/': {
      preset: 'client',
      documentTransforms: [addTypenameSelectionDocumentTransform],
      config: {
        enumsAsTypes: true,
      },
    },
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

You can also write custom transforms to strip fields annotated with @client or rename operations for better cache key consistency.

Apollo Client Setup

If you use Apollo instead of React Query, switch the plugin:

generates: {
  './src/generated/graphql.ts': {
    plugins: [
      'typescript',
      'typescript-operations',
      'typescript-react-apollo',
    ],
    config: {
      withHooks: true,
      withComponent: false,
      withHOC: false,
    },
  },
},
Enter fullscreen mode Exit fullscreen mode

This generates useGetUserQuery and useUpdateUserMutation hooks that wrap Apollo's useQuery and useMutation with full typing. The component API is identical -- you just swap the import source.

Custom Fetcher

The React Query plugin needs a fetcher function to actually execute GraphQL requests. Here is a minimal fetcher that works with any GraphQL endpoint:

// src/lib/fetcher.ts
export function useFetchData<TData, TVariables>(
  query: string
): (variables?: TVariables) => Promise<TData> {
  return async (variables?: TVariables) => {
    const response = await fetch('http://localhost:4000/graphql', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${getToken()}`,
      },
      body: JSON.stringify({ query, variables }),
    });

    const json = await response.json();

    if (json.errors) {
      const message = json.errors
        .map((e: { message: string }) => e.message)
        .join('\n');
      throw new Error(message);
    }

    return json.data as TData;
  };
}
Enter fullscreen mode Exit fullscreen mode

The fetcher config in codegen.ts points to this function. When the generated hooks call useFetchData, they pass the query document string and variables, and this function handles the HTTP layer. You can add authentication headers, error formatting, retry logic, or anything else your API requires.

For production apps, you typically read the endpoint URL from environment variables and add request interceptors for token refresh:

const API_URL = process.env.NEXT_PUBLIC_GRAPHQL_URL ?? 'http://localhost:4000/graphql';
Enter fullscreen mode Exit fullscreen mode

Fragments for Reusable Type Selections

When multiple queries share the same selection of fields, use fragments to avoid duplication:

# src/graphql/fragments/userFields.graphql
fragment UserFields on User {
  id
  name
  email
  role
  avatar
}
Enter fullscreen mode Exit fullscreen mode
# src/graphql/queries/getUser.graphql
query GetUser($id: ID!) {
  user(id: $id) {
    ...UserFields
    posts {
      id
      title
      publishedAt
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The codegen recognizes fragments automatically. It generates a UserFieldsFragment type and inlines it into every query that references ...UserFields. This means changing the fragment in one place updates the types everywhere. Combined with the dedupeFragments: true config option, duplicate fragment definitions are merged, keeping the output file clean.

CI Integration

Add codegen to your CI pipeline to catch schema drift early:

# .github/workflows/codegen-check.yml
name: GraphQL Codegen Check
on: [pull_request]
jobs:
  codegen:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run codegen
      - name: Check for uncommitted changes
        run: |
          git diff --exit-code src/generated/
          echo "Generated types are up to date"
Enter fullscreen mode Exit fullscreen mode

If a PR changes a .graphql file but forgets to run codegen, the diff check will fail.

Quick Conversions During Development

Sometimes you need to quickly convert a GraphQL schema to TypeScript interfaces for prototyping or documentation purposes, without setting up the full codegen pipeline. The free GraphQL to TypeScript converter handles this in the browser -- paste your schema and get TypeScript types immediately. It is useful for exploring an API schema before you commit to a full codegen setup.

For a deeper walkthrough of the codegen workflow with advanced configuration options, check the full GraphQL to TypeScript codegen guide.

Key Takeaways

  • One source of truth. Your GraphQL schema defines the types. TypeScript consumes them. Nothing is manually maintained.
  • Compile-time safety. Schema changes surface as TypeScript errors, not runtime crashes.
  • Generated hooks. Whether you use React Query or Apollo, the codegen produces ready-to-use hooks with full type inference.
  • Document transforms. Modify operations at build time for cache normalization, client field stripping, or custom conventions.
  • CI enforcement. A simple diff check ensures generated code is never stale in your repository.

Stop writing GraphQL types by hand. Let the machine do it.

Top comments (0)