GraphQL-Based Code Generation — Moving Contract Errors Into the Build
Photo by Andrea Albanese on Pexels
Parts 3 and 4 built us a composed schema and a delegated field. Both of those are server-side achievements that the browser can’t see at all. So this article is about the other half of the bargain Part 1 struck: if the schema is machine-readable, then the client’s view of it ought to be derived from that schema rather than transcribed by hand, and the derivation ought to fail the build whenever the two disagree.
The example is the same fictional document application as before. Note that the generated file names and wrapper shapes below are one application’s convention, not something every GraphQL generator spits out by default.
Table of Contents
- The Day the Revision Becomes Nullable
- The Operation Is the Source of Truth
- Three Generated 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
So a backend change comes in that has nothing whatsoever 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 perfectly correct change. It also happens to be the exact change that a handwritten client type can’t survive quietly. If the browser’s interface says revision: number and the component renders Revision {{ document.revision }}, then for imported documents the page reads “Revision” followed by precisely nothing, and if anything formats or compares the value it throws. Nothing failed at build time, because the handwritten type only records what some developer assumed the API returns. The compiler checks the component against that assumption and never against the API itself, so a wrong assumption compiles just as happily as a right one.
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 here 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 gets validated (do these fields actually exist on these types, are the argument types right, is the selection legal?) and the selected result shape gets generated as TypeScript.
The generated result type has one property that makes the entire exercise worthwhile, and it’s the very property developers keep being tempted to throw away: it preserves nullability exactly as the schema declares it. document is nullable because a bad id returns null. revision is nullable after our backend change. description is nullable because Part 4’s delegated field can fail independently of the document.
And the temptation, of course, is to configure the generator to emit non-null types, because optional chaining everywhere is tedious. Don’t. The optionality is the contract. A generator that flattens it gives you types that are more pleasant and not one bit more trustworthy than the handwritten ones you just deleted.
Three Generated Ways to Call the Same Operation
Nothing in this section is written by hand. The developer writes exactly one thing, the DocumentDetails.graphql file above, and the generator emits three entry points from it, because components need the data at different moments. useDocumentDetailsQuery, useDocumentDetailsClient and the typed document are all generated artifacts whose names derive from the operation name in that file. Rename the operation and the generated symbols follow along.
Where that generation runs matters just as much as what it produces, and it is emphatically not a script somebody has to remember to invoke. The generator is packaged as a Nuxt module, the GraphQL toolkit that Part 7 describes in detail, so Nuxt runs it as part of its own lifecycle: once when the dev server starts, once during a production build, and, in development only, again every time a .graphql file is saved. The output lands in the build directory and gets registered for auto-import, which is why none of the snippets below have import statements. Treat it as compiler output rather than as source. It gets regenerated, not edited.
The reactive composable is the default for data a page needs in order to render at all, and it’s the one that has to cooperate with SSR:
const { data, pending, error } = useDocumentDetailsQuery({ id: documentId })
A wrapper that’s worth generating gives you typed variables and results, a stable cache key, payload serialization on the server with reuse during hydration, and loading and error states shaped like the rest of the application. The key is where all the subtlety lives, for the reasons Part 2 gave: it has to include the variables and any request context that changes the result, or the browser will happily reuse a payload that was rendered for something else entirely. And do notice what the wrapper doesn’t promise. If the variables change, if the payload is missing, or if the caller asks for a refresh, you get a second fetch. “No duplicate request” is a property of matching keys, not a property 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 happily share transport machinery, but they must not share a retry policy by default: retrying a read is harmless, whereas retrying a write is how one submission quietly 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. You reach for this one when the wrapper is in your way. Writing a custom link, for instance, which is Apollo’s term for a piece of middleware in the chain that every operation passes through on its way to the network, used for things like attaching headers or logging slow operations. The same goes for writing a result straight into the client cache after a mutation, or calling the client from code that isn’t a component at all. Generating it matters for a reason that has very little to do with convenience: if the composable is the only way to use an operation, then every unanticipated need turns into a request to change the generator. An escape hatch keeps the generated layer optional.
Reusing Selections With Generated Fragments
Editorial content from a CMS is hardly ever just 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 ends up identical in every operation that needs it. Copying that block around is a brand new synchronization problem, of precisely the kind this whole series is trying to get rid of.
A generator can inspect the CMS schema, find the rich-text structure and its link types, and emit reusable fragments that operations then include. And because those fragments are derived from the same schema the operations validate against, they can’t drift away from it.
Two cautions belong with the decision to generate them, though. The shape is CMS-specific: a field called json tells you absolutely nothing about which linked types can turn up 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 operation including it larger, meaning more response bytes, more query complexity, more downstream resolution. So 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 rather than just the diff of the generator.
Interfaces Unions and Possible Types
CMS content models lean heavily 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 some cached object, it needs to know about that relationship, and the schema is the only place it’s written down.
// Generated: cms-possible-types.ts
export const cmsPossibleTypes = {
ContentBlock: ['TextBlock', 'ImageBlock'],
}
Let’s be precise about what this map actually does. It tells Apollo which concrete types satisfy which abstract type, so that a cached object stored as a TextBlock gets recognized as a valid target for a fragment written against ContentBlock. Without the map, the client has no way of knowing the two are related, and the fragment just gets skipped.
What it does not do is tell the client what any individual object is. That comes from __typename, a field every GraphQL type exposes which returns its concrete type name as a string. The client needs it on each object in order to look that object up in the map, so operations still have to select it, either explicitly or through the client’s defaults. And the map won’t make a fragment match an object whose concrete type is missing from the map. So a new block type that was added in the CMS and never regenerated here turns up as a fragment which silently fails to match, and that reads like missing content rather than like a stale file.
Where the Checks Actually Run
All of the above is only as good as the moment it executes, so the phases are worth naming 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 cheerfully transpile TypeScript without type-checking any of it, so a green build tells you nothing at all about whether the generated types are satisfied. That check has to be an explicit command in CI.
Steps 1 and 3 need a decision about determinism. In development, a watcher that regenerates 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 the committed generated output matches what the current schema would produce. Skip that and you get the very failure mode this article exists to prevent, one level up: a generated file from three weeks ago, describing a schema that doesn’t exist any more, type-checking absolutely perfectly.
And if generation introspects a live endpoint, decide what happens when that endpoint is unavailable or halfway through a deployment. Failing the build is usually right. Silently falling back to a stale snapshot is usually wrong.
There’s one boundary I want to keep clear, because the word “schema” does double duty here. This is build-time generation. The running gateway may well load or refresh subgraph schemas at startup or on demand, and that has no effect whatsoever on the types compiled into a browser bundle that was built last Tuesday. Schema evolution at run time and contract checking at build time are separate mechanisms with separate failure modes.
The Nullable Revision Revisited
So, back to our imported documents. The backend makes revision nullable, the schema snapshot gets refreshed, and the pipeline runs.
Validation passes, because revision still exists and the operation is therefore legal. Generation changes the result type from number to number | null. And then the type check fails at every single 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. Every one of those failures points at a real decision somebody has to make about imported documents, and that’s the useful part. Nobody has to happen to notice that the backend changed. The build insists on it.
What the build still can’t tell you is what the page should do. “Revision —” for imported documents, a hidden label, or a different layout altogether is a product decision, and all the type error guarantees is that the decision gets made rather than defaulted. Which is the correct division of labour: generation turns a silent runtime surprise into a visible decision with an address.
What Generation Does Not Establish
There are three claims that are very easy to attach to generated types, and not one of them holds.
It doesn’t prove the response obeys the contract. The types describe the schema the generator happened to see. A subgraph returning a malformed payload, or a gateway returning null for a non-null field along with an error, won’t be caught by a type system that no longer exists at run time. Validation at trust boundaries stays the server’s job.
It doesn’t establish authorization. A field that’s present in the schema and typed in the client tells you nothing about whether this particular visitor may read it. Part 15 covers where those decisions belong.
And it doesn’t establish compatibility with clients already out in the field. A browser holding a bundle from the previous release is still sending the previous operations, so a schema change that sails through today’s checks can break yesterday’s client. That’s a deployment concern, and it belongs to Part 16.
What’s left over is worth having anyway, and I’d state it as a relocation rather than an elimination: a class of failure that used to show up in a browser, miles from its cause, now shows up in a build right next to it. That’s the whole claim. All the other runtime failures listed above are still yours to handle. Mismatches between the client’s types and the schema just aren’t among them any more.
What’s Next
- Part 6: Nuxt Module Boundaries — Where generated code and its consumers live, and how to keep module boundaries checkable.
- Part 7: Two Toolkit Modules — 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: Nuxt in Production