Type‑Safe GraphQL Code Generation: Harnessing TypeScript, Zod, and Custom AST Transformers for End‑to‑End Guarantees
Introduction
GraphQL’s declarative schema language is a powerful contract between client and server, but the moment data crosses the wire the static guarantees of TypeScript disappear. A common pattern is to generate TypeScript types from the GraphQL schema and then manually write validators or rely on runtime checks scattered throughout the code‑base. This approach leaves three safety holes:
- Schema drift – the generated types may diverge from the actual resolver implementation.
- Unchecked inputs – raw GraphQL variables are sent to the server without validation, risking runtime errors or security issues.
- Inconsistent error handling – developers often duplicate validation logic on both client and server, increasing maintenance cost.
In this article we will build an end‑to‑end type‑safe stack that eliminates those gaps:
- TypeScript – the language of choice for static analysis.
- Zod – a zero‑dependency schema validator that can be inferred from TypeScript types and can generate TypeScript types from its schemas.
- Custom AST transformer – a tiny
ts‑morph/ts‑cjsplugin that we run during the compilation step to auto‑inject Zod validators into generated GraphQL operation files.
By the end of the guide you will have:
- A GraphQL schema that serves as the single source of truth.
- Generated TypeScript operation modules (
.ts) that export:- The operation’s document (
gqlstring). - A typed variables interface (
Variables). - A Zod validator (
variablesSchema). - A typed response interface (
Response).
- The operation’s document (
- A runtime guard that validates incoming variables on the server and outgoing variables on the client, with zero manual boilerplate.
The solution works with any GraphQL client (Apollo, urql, graphql‑request) and any GraphQL server (Apollo Server, Yoga, Nexus, etc.) because the contracts are expressed as pure TypeScript modules.
Prerequisites
| Tool | Reason |
|---|---|
| Node ≥ 18 | Native ESM support & top‑level await |
| TypeScript ≥ 5.2 | Supports type imports and import type‑only mode used by the transformer |
graphql (npm) |
Parses the schema and documents |
zod |
Runtime validation library |
ts-morph |
Lightweight wrapper for the TypeScript compiler API |
@graphql-codegen/cli |
Generates base types and operation documents |
esbuild (optional) |
Fast compilation for the transformer step |
You can install the required packages with:
npm i -D typescript ts-morph @graphql-codegen/cli graphql zod esbuild
1. Generating Base Types with GraphQL Code Generator
First we let graphql-codegen produce the raw TypeScript types from the GraphQL schema. Create a codegen.yml:
schema: "./src/schema.graphql"
documents: "./src/**/*.gql"
generates:
./src/generated/graphql.ts:
plugins:
- "typescript"
- "typescript-operations"
config:
avoidOptionals: true
enumsAsTypes: true
Running npx graphql-codegen creates a file that looks like:
export type Maybe<T> = T | null;
export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
};
export type Query = {
__typename?: 'Query';
user: User;
};
export type User = {
__typename?: 'User';
id: Scalars['ID'];
name: Scalars['String'];
email: Scalars['String'];
};
And for each operation in src/**/*.gql we get a corresponding OperationDocument, OperationVariables, and Operation type. This is the static side of our contract.
2. Turning Types into Zod Schemas
Zod can infer a schema from a TypeScript type using the z.object factory, but we need automation. We'll write a small utility that consumes the generated types and produces Zod schemas for variables only (responses are already validated by the server).
Create src/util/zodFromTypes.ts:
import { z } from 'zod';
import type { QueryUserVariables } from '../generated/graphql';
export const queryUserVariablesSchema = z.object({
// The GraphQL Codegen generated a type like:
// export type QueryUserVariables = { id: string };
// We manually map each property to a Zod validator.
id: z.string().uuid(),
});
export type QueryUserVariables = z.infer<typeof queryUserVariablesSchema>;
Manually writing schemas defeats the purpose, so we will generate them automatically with a custom AST transformer (next section). The manual example above merely illustrates the shape that the transformer will emit.
3. Custom AST Transformer – The Glue
3.1 What the transformer does
When the compiler processes a file like src/operations/GetUser.gql.ts (generated by graphql-codegen), the transformer will:
- Detect the exported
type *Variablesinterface. - Read its property signatures using the TypeScript type checker.
- Emit a named Zod schema (
*VariablesSchema) that mirrors the structure, using sensible defaults (e.g.,z.string(),z.number(),z.boolean()). - Add an exported helper
validateVariablesthat throws a descriptive error if validation fails.
All of this happens once, at compile time, so the runtime bundle contains only the generated schema and helper – no reflection or extra code.
3.2 Implementation
Create transformer.ts:
import { Project, SyntaxKind, VariableStatement, InterfaceDeclaration } from 'ts-morph';
import { writeFileSync } from 'fs';
import { resolve } from 'path';
// Helper that maps TS type nodes to Zod constructors
function mapTsTypeToZod(node: any): string {
switch (node.getKind()) {
case SyntaxKind.StringKeyword: return 'z.string()';
case SyntaxKind.NumberKeyword: return 'z.number()';
case SyntaxKind.BooleanKeyword: return 'z.boolean()';
case SyntaxKind.TypeReference: {
const name = node.getText();
if (name === 'Date') return 'z.coerce.date()';
// Fallback to any for unknown references
return 'z.any()';
}
case SyntaxKind.ArrayType: {
const element = mapTsTypeToZod(node.getElementTypeNode());
return `z.array(${element})`;
}
case SyntaxKind.UnionType: {
const types = node.getUnionTypes().map(mapTsTypeToZod);
return `z.union([${types.join(', ')}])`;
}
default:
return 'z.any()';
}
}
// Main transformer
export async function runTransformer(entryGlob: string) {
const project = new Project({
tsConfigFilePath: resolve('tsconfig.json'),
skipAddingFilesFromTsConfig: true,
});
project.addSourceFilesAtPaths(entryGlob);
for (const sourceFile of project.getSourceFiles()) {
const variableInterfaces = sourceFile.getInterfaces()
.filter(i => i.getName().endsWith('Variables'));
if (variableInterfaces.length === 0) continue;
for (const iface of variableInterfaces) {
const name = iface.getName(); // e.g. GetUserVariables
const schemaName = `${name}Schema`;
const properties = iface.getProperties();
const shape: string[] = properties.map(p => {
const propName = p.getName();
const propType = p.getTypeNodeOrThrow();
const zodExpr = mapTsTypeToZod(propType);
return `${propName}: ${zodExpr}`;
});
// Insert the schema after the interface
const schemaStmt = `export const ${schemaName} = z.object({\n ${shape.join(',\n ')}\n});`;
sourceFile.insertText(iface.getEnd() + 1, `\n\n${schemaStmt}\n`);
// Export a validator helper
const validatorStmt = `
export function validate${name}(vars: unknown): ${name} {
const result = ${schemaName}.safeParse(vars);
if (!result.success) {
throw new Error('Invalid GraphQL variables: ' + JSON.stringify(result.error.format()));
}
return result.data;
}`;
sourceFile.insertText(iface.getEnd() + schemaStmt.length + 2, `\n${validatorStmt}\n`);
}
await sourceFile.save();
}
}
// Run if executed directly
if (require.main === module) {
const pattern = process.argv[2] ?? 'src/generated/**/*.ts';
runTransformer(pattern).catch(console.error);
}
How it works
ts-morphparses the TypeScript source files using the sametsconfig.jsonsettings, guaranteeing the same type resolution as the rest of the project.- The transformer looks for any interface whose name ends with
Variables. This convention matches the output ofgraphql-codegen. - For each property it builds a Zod expression via
mapTsTypeToZod. The mapping is deliberately simple; you can extend it to support custom scalars (e.g.,Email,JSON) by checking the type name and returningz.string().email()orz.any()respectively. - The generated schema and validator are inlined into the same file, keeping import graphs shallow and preserving tree‑shaking.
3.3 Wiring the transformer into the build
Add a script to package.json:
{
"scripts": {
"generate:schema": "ts-node transformer.ts src/generated/**/*.ts",
"build": "npm run generate:schema && esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js"
}
}
Running npm run build will:
- Run the transformer to enrich every generated operation file with a Zod schema and a
validate*helper. - Bundle the project with
esbuild(ortsc) for production.
4. Using the Generated Artifacts
4.1 Client‑side example (graphql‑request)
import { request } from 'graphql-request';
import { GetUserDocument, GetUserVariables, validateGetUserVariables } from './generated/GetUser.gql';
export async function fetchUser(input: GetUserVariables) {
// Compile‑time: `input` matches the generated `GetUserVariables` type.
// Runtime: the shape is validated before the request leaves the client.
const safeInput = validateGetUserVariables(input);
return request<{ user: { id: string; name: string } }>(
'https://api.example.com/graphql',
GetUserDocument,
safeInput
);
}
If a developer accidentally passes id: 123 (a number), TypeScript already flags the mismatch, and the Zod guard adds a runtime safety net for data coming from dynamic sources (e.g., URLSearchParams).
4.2 Server‑side example (Apollo Server)
import { ApolloServer } from '@apollo/server';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { validateGetUserVariables } from './generated/GetUser.gql';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
requestDidStart() {
return {
async executionDidStart({ request }) {
if (request.operationName === 'GetUser') {
// `request.variables` is `any` at this point.
// We coerce it into the strongly typed shape.
request.variables = validateGetUserVariables(request.variables);
}
},
};
},
},
],
});
await server.listen({ port: 4000 });
Now the resolver receives guaranteed‑valid variables, and you can write resolver code without defensive checks:
export const resolvers = {
Query: {
async user(_: any, { id }: { id: string }) {
// `id` is a validated UUID string.
return await db.user.findUnique({ where: { id } });
},
},
};
5. Handling Custom Scalars
GraphQL schemas often define scalars like DateTime or Email. To keep the end‑to‑end guarantee, we map those scalars to Zod refinements.
- Extend the codegen config to output scalar mappings:
config:
scalars:
DateTime: string
Email: string
- Add a helper file
src/customScalars.ts:
import { z } from 'zod';
export const DateTime = z.string().datetime();
export const Email = z.string().email();
- Update the transformer’s
mapTsTypeToZodfunction:
if (name === 'DateTime') return 'DateTime';
if (name === 'Email') return 'Email';
- Ensure the generated files import the custom validators:
import { DateTime, Email } from '../customScalars';
Now custom scalar validation is part of the generated schema automatically.
6. Benefits and Trade‑offs
| Benefit | Explanation |
|---|---|
| Zero manual validation | The transformer writes the validator once; developers never touch Zod code again. |
| Single source of truth | The GraphQL schema → generated TypeScript types → Zod schema. Any change propagates automatically. |
| Full IDE support | Autocomplete, hover docs, and compile‑time errors work for both variables and responses. |
| Runtime safety for dynamic inputs | Even when variables come from JSON.parse or URL strings, the guard catches malformed data before the GraphQL layer. |
| Performance | Zod’s parse is fast; the generated schema is tree‑shakable, so production bundles stay small. |
Trade‑offs
- Build step complexity – Adding a custom transformer introduces an extra compilation stage. However, the script can be cached and runs in milliseconds for typical codebases.
- Limited to generated operations – Hand‑written GraphQL strings won’t get automatic validators unless you feed them through the same pipeline.
- Scalar mapping is manual – You must decide how each custom scalar should be validated; the transformer only plugs in the identifiers.
7. Extending the Pattern
- Response validation – For highly critical APIs you can generate a response Zod schema using the same technique, then validate the server’s JSON payload on the client.
- Persisted queries – Store the generated document strings in a CDN; the validator stays local, guaranteeing the same contract even if the CDN is outdated.
- Monorepo sharing – Publish the generated
*.gql.tsfiles as a package (@myorg/graphql-types) that both frontend and backend consume, ensuring perfect alignment across services.
8. Conclusion
By combining GraphQL Code Generator, Zod, and a custom TypeScript AST transformer, we achieve a truly end‑to‑end type‑safe GraphQL stack:
- The schema is the authoritative definition.
- Types flow automatically from schema to client and server.
- Zod validators are generated once, guaranteeing runtime safety without repetitive code.
The approach scales from small hobby projects to large monorepos, and because it leans on the TypeScript compiler API, it stays in sync with your existing tooling. Give it a try in your next GraphQL migration and enjoy the peace of mind that comes from having both compile‑time and runtime guarantees.
Member discussion