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:
schemasupplies 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.documentsis the glob over files containing operations — template literals tagged withgraphqland standalone.graphqldocuments, fragments included. Forget this glob and you get schema types with no operation types, which looks like success until you try to import one.typescriptemits the schema-side types (enums, inputs, object shapes).typescript-operationsemits one result and variables type per operation, filtered to the fields you actually selected.typed-document-nodebinds the two to the document constant so the client library infers types without casts.scalarsmaps custom scalars to TypeScript. Without it, a customDateTimebecomesanyand your date handling silently loses its type; withDateTime: 'Date'the generated field is a realDate.near-operation-filewrites each operation's types next to the component that uses them, with one sharedtypes.tsfor 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
afterOneFileWritehook), the in-memory output never matches what--checkcompares. Run the formatter through codegen'sbeforeAllFileWritehook 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--checkenforce 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:
- Schema types without operation types — the
documentsglob is wrong or thegraphqltag import is missing from the file. - Fragments invisible to components — a shared fragment lives outside the glob, so operation types silently omit its fields.
- Custom scalars typed as
any— noscalarsmapping; every date and money value loses its type at the boundary. - 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
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?
2. What does the scalars mapping { DateTime: "Date" } accomplish?
3. What is the near-operation-file preset for?
4. A teammate edits a query and forgets to regenerate. How does CI catch it?
Flashcards
Question
What codegen actually generates
Click to reveal answer
Answer
Per-operation result and variables types from your selection, plus schema types, bound to the document via TypedDocumentNode.
Question
documents vs schema inputs
Click to reveal answer
Answer
schema supplies the type system; documents supplies operations and fragments. Wrong documents glob => schema types only.
Question
Custom scalar mapping
Click to reveal answer
Answer
scalars: { DateTime: "Date" } replaces the default any with a real TypeScript type in generated output.
Question
near-operation-file
Click to reveal answer
Answer
Generated types colocate with the consuming component; one shared base file holds schema-side types.
Question
Freshness gate
Click to reveal answer
Answer
graphql-codegen --check: dry-run, exit 1 on stale output; run in CI and keep Prettier hooks symmetric.
Question
Selection-shaped types
Click to reveal answer
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