Skip to content
intermediate Phase 12 · Developer Tooling: Introspection, GraphiQL, and Codegen

Generating TypeScript Types from Schemas

Running GraphQL codegen to emit typed operations and eliminate untyped query strings.

55m
1 problems
Topic Progress 0%

The Query String That Lies

The Query String That Lies

Every GraphQL client ships operations as strings. TypeScript stops at the quotation mark: the document is a DocumentNode, the result is any, and nothing connects the two to your schema.

const QUERY = gql`
  query GetUser($id: ID!) {
    user(id: $id) { id fullName }
  }
`;

const data: any = await client.request(QUERY, { id });
data.user.fullName.toUpperCase();   // compiles no matter what the schema says

Rename fullName back to name in the schema and this file still compiles. The failure arrives in production as Cannot query field "fullName" on type "User" — the exact error a schema check or a typed build would have surfaced at review time.

Code generation closes the gap by making the types derive from the selection you wrote, not just from the schema:

type GetUserQuery = {
  user: { id: string; fullName: string } | null;   // mirrors schema nullability exactly
};
type GetUserQueryVariables = { id: string };

const GetUser: TypedDocumentNode<GetUserQuery, GetUserQueryVariables> = graphql`
  query GetUser($id: ID!) { user(id: $id) { id fullName } }
`;

const data = await client.request(GetUser, { id: 1 });
data.user?.fullName;   // string | null | undefined — the compiler knows the shape

Two properties of that generated type do the real work. It is selection-shaped: drop fullName from the query and the property disappears from the type, so client code reading it fails to compile. And it is schema-accurate: every ! you wrote in the schema becomes a non-nullable property and every nullable position becomes | null, so the null checks clients write match reality.

The alternative — generated schema types used manually — helps less than it appears to. A User type describes the whole object; your query may select three fields of it, and a variable's type says nothing about which arguments the operation accepts. Operation types (result plus variables) are what make client code verifiable.

Wiring the Generator

Wiring the Generator

graphql-codegen is a plugin pipeline: inputs (schema + documents), a set of plugins that each emit TypeScript, and an output location. A configuration that fits most applications:

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

const config: CodegenConfig = {
  // SDL file, not a live endpoint: CI never needs the server running
  schema: './schema/schema.graphql',
  documents: ['src/**/*.{ts,tsx}', 'src/**/*.graphql'],
  generates: {
    'src/gql/types.ts': { plugins: ['typescript'] },
    'src/gql/': {
      preset: 'near-operation-file',
      presetConfig: {
        extension: '.generated.ts',
        baseTypesPath: 'types.ts',
      },
      plugins: ['typescript-operations', 'typed-document-node'],
    },
  },
  scalars: {
    DateTime: 'Date',
    JSON: 'Record<string, unknown>',
  },
};
export default config;

What each piece is doing:

  • schema supplies the type system. Pointing at the committed SDL (the artifact from introspection mechanics) keeps builds deterministic; pointing at a live URL works for local development but fails the moment introspection is disabled.
  • documents is the glob over files containing operations — template literals tagged with graphql and standalone .graphql documents, fragments included. Forget this glob and you get schema types with no operation types, which looks like success until you try to import one.
  • typescript emits the schema-side types (enums, inputs, object shapes). typescript-operations emits one result and variables type per operation, filtered to the fields you actually selected. typed-document-node binds the two to the document constant so the client library infers types without casts.
  • scalars maps custom scalars to TypeScript. Without it, a custom DateTime becomes any and your date handling silently loses its type; with DateTime: 'Date' the generated field is a real Date.
  • near-operation-file writes each operation's types next to the component that uses them, with one shared types.ts for schema-side definitions. The alternative — one giant output — is simpler until three teams merge generated files into the same path.

The result after graphql-codegen --config codegen.ts:

// src/gql/GetUser.generated.ts
export type GetUserQuery = { user: { id: string; fullName: string } | null };
export type GetUserQueryVariables = { id: string };
export const GetUserDocument: TypedDocumentNode<GetUserQuery, GetUserQueryVariables>;

Freshness: Keeping Generated Types Honest

Freshness: Keeping Generated Types Honest

Generated types are a compiler artifact of two inputs. Change the schema or the query and the artifact is wrong until someone regenerates — and stale types are worse than no types, because they turn "the compiler would have caught this" into "the compiler agreed with a lie."

The mechanical fix is a check, not a habit:

{
  "scripts": {
    "codegen": "graphql-codegen --config codegen.ts",
    "codegen:watch": "graphql-codegen --config codegen.ts --watch",
    "codegen:check": "graphql-codegen --config codegen.ts --check"
  }
}

--check generates into memory and compares against the files on disk: exit 0 when identical, exit 1 when anything is stale, nothing written. Run it in CI alongside the type check so a pull request that edits operations or schema without regenerating fails before review.

Two operational traps worth knowing before you trust it:

  • Formatting hooks skew the comparison. If Prettier rewrites generated files after they hit disk (an afterOneFileWrite hook), the in-memory output never matches what --check compares. Run the formatter through codegen's beforeAllFileWrite hook as well, so both sides of the comparison are formatted the same way.
  • Commit or ignore generated files — choose deliberately. Committing them means type errors show up in review diffs and downstream packages compile without running codegen, at the cost of occasional merge conflicts in generated paths. Ignoring them keeps the repo clean but makes every build dependent on a codegen step, and a missed step ships any. Most teams commit them and let --check enforce freshness.

Watch mode covers the local loop (codegen:watch in a second terminal), and a pre-commit hook running --check catches staleness before it reaches CI — useful when the build queue is slower than the habit you are trying to build.

Common failure modes, in the order they appear in real projects:

  1. Schema types without operation types — the documents glob is wrong or the graphql tag import is missing from the file.
  2. Fragments invisible to components — a shared fragment lives outside the glob, so operation types silently omit its fields.
  3. Custom scalars typed as any — no scalars mapping; every date and money value loses its type at the boundary.
  4. Two schemas, one config — monorepos need separate configs per schema; merging documents from different schemas produces types that satisfy neither.

Interview framing: the question is usually "what happens when the schema changes?" Answer with the loop — schema edit regenerates types, a stale selection becomes a compile error, CI's --check guarantees regeneration actually happened — and mention that operation types, not schema types, are what make client code provably correct.

Practice Problems

0 / 1 solved
Configure Codegen for a Colocated React App
Codegen setup

The app keeps schema/schema.graphql in the repo, writes queries in .graphql files beside components under src/features, defines a shared fragment in src/shared/fragments.graphql, and uses a custom DateTime scalar. Produce a codegen config and the npm scripts that generate, watch, and verify the output in CI.

Example:

Input: src/features/orders/RecentOrders.graphql + src/shared/fragments.graphql + schema/schema.graphql (scalar DateTime)

Output: base types at src/gql/types.ts; per-document .generated.ts beside each component importing the shared base; DateTime typed as Date

The documents glob must reach both feature files and the shared fragment directory, while the base types file gives every near-operation-file output one import target.

Optimal Solution — O(n) time, O(n) space

Point schema at the committed SDL, glob documents across features and shared fragments, emit schema types once to a base file and per-operation types next to each document with the near-operation-file preset, map custom scalars, and add generate/watch/check scripts where check is the CI gate.

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

const config: CodegenConfig = {
  schema: './schema/schema.graphql',
  documents: ['src/**/*.graphql', 'src/**/*.{ts,tsx}'],
  generates: {
    'src/gql/types.ts': { plugins: ['typescript'] },
    './src': {
      preset: 'near-operation-file',
      presetConfig: { extension: '.generated.ts', baseTypesPath: 'gql/types.ts' },
      plugins: ['typescript-operations', 'typed-document-node'],
    },
  },
  scalars: { DateTime: 'Date' },
  hooks: {
    afterAllFileWrite: ['prettier --write'],
    beforeAllFileWrite: ['prettier --write'],   // keeps --check comparisons honest
  },
};

export default config;

// package.json scripts
// "codegen": "graphql-codegen --config codegen.ts"
// "codegen:watch": "graphql-codegen --config codegen.ts --watch"
// "codegen:check": "graphql-codegen --config codegen.ts --check"   // CI gate, exit 1 = stale

Edge Cases:

  • Fragments imported across package boundaries (raise documents globs to the workspace root)
  • Generated files formatted by a repo-level Prettier config not visible to hooks
  • A second schema in the same repo (separate config file, separate output tree)

Quiz

1. Why does a schema field rename break a typed client build but not a raw gql string client?

Question 1 options

2. What does the scalars mapping { DateTime: "Date" } accomplish?

Question 2 options

3. What is the near-operation-file preset for?

Question 3 options

4. A teammate edits a query and forgets to regenerate. How does CI catch it?

Question 4 options

Flashcards

Question

What codegen actually generates

Answer

Per-operation result and variables types from your selection, plus schema types, bound to the document via TypedDocumentNode.

Question

documents vs schema inputs

Answer

schema supplies the type system; documents supplies operations and fragments. Wrong documents glob => schema types only.

Question

Custom scalar mapping

Answer

scalars: { DateTime: "Date" } replaces the default any with a real TypeScript type in generated output.

Question

near-operation-file

Answer

Generated types colocate with the consuming component; one shared base file holds schema-side types.

Question

Freshness gate

Answer

graphql-codegen --check: dry-run, exit 1 on stale output; run in CI and keep Prettier hooks symmetric.

Question

Selection-shaped types

Answer

Dropping a field from the query removes it from the type, so stale client reads fail to compile.

Revision Notes

Key Takeaways

  • 1. Untyped documents turn schema renames into production errors; typed documents turn them into build errors
  • 2. Operation types mirror your selection and the schema’s nullability — schema types alone cannot do that
  • 3. Map custom scalars or they arrive as any at every boundary
  • 4. Freshness is enforced by --check in CI, not by remembering to run a script

Interview Tips

  • • Explain the difference between generated schema types and generated operation types, and why only the latter verify client code
  • • Describe your staleness story: watch locally, --check in CI, deliberate commit-or-ignore policy
  • • Be ready to debug the classic "types exist but my component cannot import them" — a documents glob miss

Cheat Sheet

TypeScript Codegen Cheat Sheet

// codegen.ts
const config: CodegenConfig = {
  schema: './schema/schema.graphql',                 // artifact, not a live URL
  documents: ['src/**/*.{ts,tsx}', 'src/**/*.graphql'],
  generates: {
    'src/gql/types.ts': { plugins: ['typescript'] },
    'src/gql/': {
      preset: 'near-operation-file',
      presetConfig: { extension: '.generated.ts', baseTypesPath: 'types.ts' },
      plugins: ['typescript-operations', 'typed-document-node'],
    },
  },
  scalars: { DateTime: 'Date', JSON: 'Record<string, unknown>' },
};
"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
"codegen:check": "graphql-codegen --config codegen.ts --check"   // exit 1 = stale

Usage

const GetUser: TypedDocumentNode<GetUserQuery, GetUserQueryVariables> = graphql`...`;
const data = await client.request(GetUser, { id });  // data typed from the selection

Gotchas: documents glob misses fragments · unmapped scalars become any · format generated files via beforeAllFileWrite so --check stays truthful · one config per schema in monorepos

Continue Learning