GraphQL-Based Code Generation — Moving Contract Errors Into the Build
Hero image credit: Photo by Andrea Albanese on Pexels
Part 5 of the Nuxt and .NET series.
Parts 3 and 4 built a composed schema and a delegated field. Both are server-side achievements that the browser cannot see. This article is about the other half of the bargain Part 1 made: if the schema is machine-readable, the client’s view of it should be derived from the schema rather than transcribed by hand — and the derivation should fail the build when the two disagree.
The example is the same fictional document application. The generated file names and wrapper shapes below are one application’s convention, not output every GraphQL generator produces by default.
Table of Contents
- The Day the Revision Becomes Nullable
- The Operation Is the Source of Truth
- Three Ways to Call the Same Operation
- Reusing Selections With Generated Fragments
- Interfaces Unions and Possible Types
- Where the Checks Actually Run
- The Nullable Revision Revisited
- What Generation Does Not Establish
- What’s Next
The Day the Revision Becomes Nullable
A backend change arrives that has nothing to do with the frontend. Documents can now be imported from an external archive, and imported documents have no revision history, so revision becomes nullable: Int! turns into Int.
This is a correct change. It is also the exact change that a handwritten client type cannot survive quietly. If the browser’s interface says revision: number and the component renders Revision {{ document.revision }}, then the page reads “Revision” followed by nothing for imported documents — and if anything formats or compares the value, it throws. Nothing failed at build time, because the handwritten type was a developer’s belief about the API, and beliefs compile.
So the question this article answers is narrow and practical: which of my client’s assumptions about the API can a build check, and which can it not? Generation is how you move as many as possible into the first category. Knowing what stays in the second is the difference between a useful tool and false confidence.
The Operation Is the Source of Truth
The unit of work is the operation, written by a developer and stored as a file:
query DocumentDetails($id: ID!) {
document(id: $id) {
id
name
revision
description {
title
}
}
}
Two things then happen against the composed schema from Part 3. The operation is validated — do these fields exist on these types, are the argument types right, is the selection legal? — and the selected result shape is generated as TypeScript.
The generated result type has one property that makes the whole exercise worthwhile and that developers are regularly tempted to remove: it preserves nullability exactly as the schema declares it. document is nullable because a bad id returns null. revision is nullable after the backend change. description is nullable because Part 4’s delegated field can fail independently of the document.
The temptation is to configure the generator to emit non-null types because optional chaining everywhere is tedious. Do not. The optionality is the contract; a generator that flattens it produces types that are more pleasant and no more trustworthy than the handwritten ones you just deleted.
Three Ways to Call the Same Operation
One operation, three entry points, because components need the data at different moments.
The reactive composable is the default for data a page needs in order to render, and it is the one that has to cooperate with SSR:
const { data, pending, error } = useDocumentDetailsQuery({ id: documentId })
A wrapper worth generating supplies typed variables and results, a stable cache key, payload serialization on the server and reuse during hydration, and loading and error states shaped like the rest of the application. The key is where the subtlety lives, for the reasons Part 2 gave: it must include the variables and any request context that changes the result, or the browser reuses a payload that was rendered for something else. And note what the wrapper does not promise — if the variables change, if the payload is missing, or if the caller asks for a refresh, there is a second fetch. “No duplicate request” is a property of matching keys, not of using the composable.
The imperative client is for operations triggered by an action rather than by rendering:
const { execute } = useDocumentDetailsClient()
async function onSubmit() {
const result = await execute({ id: documentId })
// result is typed from the operation's selection set
}
This interface should make errors, cancellation, and retries explicit rather than ambient. Queries and mutations can share transport machinery, but they must not share a retry policy by default: retrying a read is harmless, and retrying a write is how one submission becomes two records.
The typed document is the low-level escape hatch — the operation in the form Apollo executes, plus its variable and result types — for custom links, cache manipulation, or calling the client directly. Generating it matters for a reason that has little to do with convenience: if the composable is the only way to use an operation, then every unanticipated need becomes a request to change the generator. An escape hatch keeps the generated layer optional.
Reusing Selections With Generated Fragments
Editorial content from the CMS is rarely a string. A rich-text field is typically a JSON document plus linked entries and assets, and selecting it properly takes a nested block of GraphQL that is identical in every operation that needs it. Copying that block is a new synchronization problem of precisely the kind this series is trying to eliminate.
A generator can inspect the CMS schema, find the rich-text structure and its link types, and emit reusable fragments that operations then include. Because the fragments are derived from the same schema the operations validate against, they cannot drift from it.
Two cautions belong with the decision to generate them. The shape is CMS-specific — a field called json tells you nothing about which linked types can appear inside it, so the generator needs real knowledge of that CMS’s conventions rather than a guess. And a fragment that selects every possible linked object makes every including operation larger: more response bytes, more query complexity, more downstream resolution. Keep generated fragments aligned with what the renderer can actually display, treat a generator change as an application change, and read the diff of the resulting queries, not just the diff of the generator.
Interfaces Unions and Possible Types
CMS content models lean on abstract types: a page contains a list of ContentBlock, and each element is really a TextBlock or an ImageBlock. When a client-side cache has to decide whether a fragment on ContentBlock applies to a cached object, it needs to know that relationship, and the schema is the only place it is written down.
// Generated: cms-possible-types.ts
export const cmsPossibleTypes = {
ContentBlock: ['TextBlock', 'ImageBlock'],
}
Be precise about what this map does: it tells Apollo which concrete types satisfy which abstract type. It does not produce __typename — your operations still have to select it, directly or through the client’s defaults — and it does not make a fragment match an object whose concrete type is absent from the map. A new block type added in the CMS and not regenerated here shows up as a fragment that silently fails to match, which reads like missing content rather than a stale file.
Where the Checks Actually Run
All of the above is only as good as the moment it executes, so it is worth naming the phases separately.
- Obtain the schema — a committed snapshot, or introspection of an authorized endpoint.
- Validate the operations against that schema.
- Generate types, documents, fragments, and wrappers.
- Run a TypeScript check.
- Build, then run behavioural tests.
Step 4 is the one that quietly goes missing. A modern bundler will happily transpile TypeScript without type-checking it, so a green build says nothing about whether the generated types are satisfied. The check has to be an explicit command in CI.
Steps 1 and 3 need a decision about determinism. In development, a watcher regenerating on every change is exactly what you want. In a release build, either generation runs deterministically as part of the build, or the build verifies that committed generated output matches what the current schema would produce. Skip that and you get the failure mode this article exists to prevent, one level up: a generated file from three weeks ago, describing a schema that no longer exists, type-checking perfectly.
If generation introspects a live endpoint, decide what happens when the endpoint is unavailable or mid-deployment — failing the build is usually right, and silently falling back to a stale snapshot is usually wrong.
One boundary to keep clear, because the word “schema” does double duty. This is build-time generation. The running gateway may load or refresh subgraph schemas at startup or on demand; that has no effect whatsoever on the types compiled into a browser bundle that was built last Tuesday. Schema evolution at runtime and contract checking at build time are separate mechanisms with separate failure modes.
The Nullable Revision Revisited
Back to the imported documents. The backend makes revision nullable, the schema snapshot is refreshed, and the pipeline runs.
Validation passes — revision still exists, so the operation is legal. Generation changes the result type from number to number | null. The type check then fails at every place that treated it as a number: the template that formats it, the comparison that sorts by it, the function that subtracts one revision from another. Each failure points at a real decision somebody has to make about imported documents, which is the useful part. Nobody has to notice that the backend changed; the build insists.
What the build still cannot tell you is what the page should do. “Revision —” for imported documents, a hidden label, or a different layout entirely is a product decision, and the type error only guarantees it gets made rather than defaulted. That is the correct division of labour: generation converts a silent runtime surprise into a visible, located decision.
What Generation Does Not Establish
Three claims are easy to attach to generated types and none of them hold.
It does not prove the response obeys the contract. The types describe the schema the generator saw; a subgraph that returns a malformed payload, or a gateway that returns null for a non-null field along with an error, will not be caught by a type system that no longer exists at runtime. Validation at trust boundaries stays the server’s job.
It does not establish authorization. A field present in the schema and typed in the client says nothing about whether this visitor may read it. Part 15 covers where those decisions belong.
It does not establish compatibility with clients already in the field. A browser holding a bundle from the previous release is still sending the previous operations, so a schema change that passes today’s checks can break yesterday’s client — which is a deployment concern, and Part 16’s.
What is left is worth having anyway, and it is best stated as a movement rather than an elimination: a class of failure that used to appear in a browser, far from its cause, now appears in a build, next to it. That is the whole claim. The remaining runtime failures are still yours, and now they are the only ones you have to think about at runtime.
What’s Next
- Part 6: Custom Nuxt Modules — Where generated code and its consumers live, and how to keep module boundaries checkable.
- Part 7: GraphQL Toolkit and Typed i18n — Wiring this generation into the Nuxt build so developers do not run it by hand.
- Part 8: The Compose Pattern — Separating styling decisions from rendering behaviour in the components that consume this data.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Advanced Web App With Nuxt And Net