GraphQL Code Generation: A Practical Client Tutorial with TypeScript and React

A step-by-step tutorial to generate typed GraphQL client code and React hooks with TypeScript, Apollo, or urql—plus scalars, fragments, and CI.

ASOasis
7 min read
GraphQL Code Generation: A Practical Client Tutorial with TypeScript and React

Image used for representation purposes only.

Overview

GraphQL code generation turns your schema and queries into strongly typed client code—saving you from hand‑writing TypeScript interfaces, React hooks, and boilerplate request helpers. In this tutorial, you’ll set up a codegen workflow, generate types and hooks, wire them into Apollo Client or urql, handle custom scalars, and automate the flow in watch mode and CI. Everything is incremental, so you can adopt it in an existing app without a rewrite.

What you’ll build

  • A TypeScript React client that queries a GraphQL API
  • Auto‑generated, strongly typed operations and React hooks
  • A repeatable codegen pipeline you can run locally, in watch mode, and in CI

You can substitute your own schema/endpoint. The examples assume a Books API with types like Book, Author, and scalars like DateTime.

Prerequisites

  • Node.js 18+ and npm (or pnpm/yarn)
  • Basic familiarity with GraphQL operations (query/mutation) and React
  • A running GraphQL endpoint or a local SDL schema file

Step 1: Choose a runtime client

GraphQL codegen is client‑agnostic. Common choices:

  • Apollo Client: Full‑featured cache, React hooks.
  • urql: Lightweight, modular, hooks‑first.
  • Plain fetch or graphql-request: Minimal runtime surface; great for SSR, workers, or Node services.

We’ll demonstrate Apollo and urql; the generated artifacts are shared.

Step 2: Install GraphQL Code Generator

The Guild’s GraphQL Code Generator is the de‑facto tool. Install the CLI and the modern “client preset,” which generates typed DocumentNodes and helpers without lock‑in to a single client.

# App dependencies (choose one runtime client)
npm i @apollo/client graphql
# or
npm i urql graphql

# Dev dependencies
npm i -D @graphql-codegen/cli @graphql-codegen/client-preset

Why the client preset?

  • It generates a zero‑runtime typed graphql helper and typed documents.
  • Works with Apollo, urql, TanStack Query + graphql-request, and custom fetch.
  • Encourages colocated .graphql documents, fragments, and operations.

Step 3: Point codegen at your schema and documents

Create a TypeScript config for codegen at the project root. You can use YAML if you prefer; TS offers type‑safe autocomplete.

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

const config: CodegenConfig = {
  schema: 'http://localhost:4000/graphql',
  documents: ['src/**/*.{ts,tsx,graphql,gql}'],
  generates: {
    './src/gql/': {
      preset: 'client',
      config: {
        // Map custom scalars to TS types
        scalars: {
          DateTime: 'string',
          URL: 'string',
          JSON: 'Record<string, unknown>'
        }
      }
    }
  }
};

export default config;

Notes:

  • schema can be a URL, a glob of SDL files, or multiple entries.
  • documents points to .graphql files and embedded gql strings in .ts/.tsx.
  • The ./src/gql folder will contain the generated graphql() helper, typed fragments, and documents.

Step 4: Write your first operation

Create a colocated GraphQL document next to a component.

# src/features/books/queries.graphql
fragment BookCard on Book {
  id
  title
  publishedAt
  author { id name }
}

query GetBooks($limit: Int!) {
  books(limit: $limit) {
    ...BookCard
  }
}

Step 5: Generate code

Add scripts, then run codegen.

// package.json
{
  "scripts": {
    "codegen": "graphql-codegen --config codegen.ts",
    "codegen:watch": "graphql-codegen --config codegen.ts --watch"
  }
}
npm run codegen

You’ll get:

  • src/gql/graphql.ts: the typed graphql() function and type exports
  • src/gql/fragment-masking.ts: fragment utilities
  • src/gql/ (operation documents compiled to typed DocumentNode)

Step 6: Use with Apollo Client

Set up Apollo normally, then import the typed document and pass variables with full type safety.

// src/app/apollo.tsx
import { ApolloClient, InMemoryCache, ApolloProvider, HttpLink } from '@apollo/client';

export const client = new ApolloClient({
  link: new HttpLink({ uri: 'http://localhost:4000/graphql' }),
  cache: new InMemoryCache()
});
// src/features/books/BooksList.tsx
import { useQuery } from '@apollo/client';
import { graphql } from '../../gql';

const GetBooksDocument = graphql(/* GraphQL */ `
  query GetBooks($limit: Int!) {
    books(limit: $limit) {
      id
      title
      publishedAt
      author { id name }
    }
  }
`);

export function BooksList() {
  const { data, loading, error } = useQuery(GetBooksDocument, { variables: { limit: 10 } });
  if (loading) return <p>Loading</p>;
  if (error) return <p role="alert">{error.message}</p>;
  return (
    <ul>
      {data?.books?.map(b => (
        <li key={b.id}>
          <strong>{b.title}</strong>  {b.author?.name}
        </li>
      ))}
    </ul>
  );
}

Notice you never hand‑write types. The variables and response types are inferred from the generated DocumentNode.

Step 7: Use with urql

Switching runtime clients does not change your documents.

// src/app/urql.tsx
import { createClient, Provider } from 'urql';

export const client = createClient({ url: 'http://localhost:4000/graphql' });
// src/features/books/BooksListUrql.tsx
import { useQuery } from 'urql';
import { graphql } from '../../gql';

const GetBooksDocument = graphql(/* GraphQL */ `
  query GetBooks($limit: Int!) {
    books(limit: $limit) { id title author { name } }
  }
`);

export function BooksListUrql() {
  const [{ data, fetching, error }] = useQuery({ query: GetBooksDocument, variables: { limit: 5 } });
  if (fetching) return <p>Loading</p>;
  if (error) return <p role="alert">{error.message}</p>;
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

Step 8: Fragments and type‑safety patterns

Fragments encourage reuse and unlock precise type‑narrowing.

# src/features/books/fragments.graphql
fragment AuthorName on Author { id name }
fragment BookCard on Book {
  id
  title
  author { ...AuthorName }
}
# src/features/books/bookById.graphql
query BookById($id: ID!) {
  book(id: $id) {
    ...BookCard
    ... on Textbook { gradeLevel }
    ... on Novel { genre }
  }
}

Inside React, unions and interfaces are reflected in TS discriminated unions. Inline fragments (… on Type) narrow types when you check __typename.

if (book.__typename === 'Textbook') {
  // book.gradeLevel is defined here
}

Step 9: Mutations and optimistic UI

Mutations work the same way—typed variables in, typed data out.

# src/features/books/mutations.graphql
mutation AddBook($input: AddBookInput!) {
  addBook(input: $input) {
    id
    title
    author { id name }
  }
}
import { useMutation } from '@apollo/client';
import { graphql } from '../../gql';

const AddBookDocument = graphql(/* GraphQL */ `
  mutation AddBook($input: AddBookInput!) {
    addBook(input: $input) { id title author { id name } }
  }
`);

function AddBookForm() {
  const [addBook] = useMutation(AddBookDocument);
  // variables are fully typed
  return (
    <button
      onClick={() =>
        addBook({
          variables: { input: { title: 'New', authorId: '1' } },
          optimisticResponse: {
            addBook: { id: 'temp', title: 'New', author: { id: '1', name: 'TBD', __typename: 'Author' }, __typename: 'Book' }
          }
        })
      }
    >Add</button>
  );
}

Step 10: Custom scalars and runtime validation

You mapped DateTime/URL/JSON to TypeScript in the config. If you need runtime safety (e.g., to assert URL is valid), introduce a branded type or zod/io‑ts validation at your network boundary.

Example with a branded URL string:

// src/types/url.ts
export type UrlString = string & { readonly __brand: unique symbol };
export function asUrlString(value: string): UrlString {
  new URL(value); // throws if invalid
  return value as UrlString;
}

Update codegen mapping:

config: {
  scalars: { URL: 'import("./src/types/url").UrlString' }
}

Now fields with scalar URL are typed as UrlString.

Step 11: Watch mode and pre‑commit safety

  • Development: npm run codegen:watch keeps types in sync while you edit documents.
  • Type safety: add tsc –noEmit to your test or build pipeline so invalid variable shapes fail fast.
  • Linting: ensure your .eslintignore includes generated folders or configure rules for generated files.

Step 12: Testing with MSW GraphQL

Mock at the network boundary while retaining generated types.

// src/test/handlers.ts
import { graphql as mswGraphql } from 'msw';

export const handlers = [
  mswGraphql.query('GetBooks', (req, res, ctx) => {
    return res(ctx.data({ books: [{ id: '1', title: 'Mocked', author: { id: 'a1', name: 'Alice' } }] }));
  })
];

Because the DocumentNode is named, MSW can match on operation name (GetBooks) without brittle string selectors.

Advanced: Near‑operation files and client‑specific hooks

Prefer client‑preset for portability. If you want client‑specific hooks generated near each document, use the classic plugin stack.

// codegen.ts (alternative)
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'http://localhost:4000/graphql',
  documents: ['src/**/*.{ts,tsx,graphql}'],
  generates: {
    './src/graphql-types.ts': {
      plugins: ['typescript', 'typescript-operations']
    },
    './src/': {
      preset: 'near-operation-file',
      presetConfig: {
        extension: '.generated.tsx',
        baseTypesPath: 'graphql-types.ts'
      },
      plugins: ['typescript-react-apollo']
    }
  }
};
export default config;

This yields useGetBooksQuery and friends next to the source document. Swap typescript-react-apollo for typescript-urql to target urql.

Troubleshooting checklist

  • Operation not found: Is the file matched by documents globs? Did you save before running codegen?
  • Duplicate operation name: Names must be unique across documents; keep them consistent with component names.
  • Schema conflicts: Clear caches, ensure the schema URL is reachable, or switch to a local SDL file while offline.
  • Any/unknown types: Verify scalar mappings and that your fragments/inline fragments target the correct concrete types.

Performance and DX tips

  • Keep operations small and colocated; re‑use fragments for shared UI pieces.
  • Use fragment masking (enabled by the client preset) to avoid accidentally reading fields you didn’t query.
  • Generate in CI and check in artifacts for libraries/SDKs; skip committing in app repos if churn is high.
  • Consider persisted queries for bandwidth wins in production builds.

Summary

With a few lines of configuration, GraphQL code generation removes an entire class of runtime bugs. You author plain GraphQL next to your components; the tool produces fully typed documents and helpers that integrate cleanly with Apollo, urql, or plain fetch. Start with the client preset for portability, add scalar mappings as you go, and let watch mode keep your types evergreen while you ship features faster and safer.

Related Posts