GraphQL × TypeScript × Zod: Type-safe API Development and Schema Management Using Code Generator
Introduction
In development using GraphQL, managing schemas and types is extremely important. In particular, unifying types between the backend and frontend and properly managing API changes leads to improved development efficiency and fewer bugs. This article introduces best practices for type-safe API development by combining technologies such as GraphQL Code Generator, TypeScript, Zod, and GraphQL SDL. It explains, in a unified way, how to establish a schema-driven development flow, manage schemas, validate responses, automatically generate API documentation, and automate API testing.
This article provides concrete solutions for those who have introduced GraphQL but are facing issues such as “schema change management is difficult,” “type mismatches occur on the frontend,” or “I don’t know how to validate responses.”
Introducing GraphQL Code Generator and Automatically Generating Type Definitions
What is GraphQL Code Generator?
GraphQL Code Generator is a tool that automatically generates TypeScript types and code from GraphQL schemas. This prevents type mismatches between the frontend and backend and improves development efficiency.
Benefits
The main purpose of using GraphQL Code Generator is “to leverage type definitions automatically generated from the schema and efficiently implement the frontend and backend.”
- When you change the schema, the type definitions are automatically updated
- You can enhance type safety by integrating with TypeScript
- Implementations on both the frontend and backend always stay in sync with the schema
Especially in team development or large-scale projects, introducing automatic type generation provides significant benefits in both work efficiency and bug reduction.
Installation and Basic Setup
Install @graphql-codegen/cli with the following command:
npm install --save-dev @graphql-codegen/cli
Next, add the necessary plugins. To generate TypeScript type definitions, install the following plugins:
npm install --save-dev @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo
First, define the GraphQL schema in schema.graphql.
type Author {
id: Int!
firstName: String!
lastName: String!
posts(findTitle: String): [Post]
}
type Post {
id: Int!
title: String!
author: Author
}
type Query {
posts: [Post]
}
Key point
- This is the server’s schema definition, and type definitions are generated based on this information.
If you were not using GraphQL Code Generator and wanted to access GraphQL on the client side, you might write something like the following:
import { request, gql } from 'graphql-request'
import { useQuery } from '@tanstack/react-query'
interface PostsQuery {
posts: {
id: string
title: string
author?: {
id: string
firstName: string
lastName: string
}
}[]
}
const postsQueryDocument = gql`
query Posts {
posts {
id
title
author {
id
firstName
lastName
}
}
}
`
const Posts = () => {
const { data } = useQuery<PostsQuery>('posts', async () => {
const { posts } = await request(endpoint, postsQueryDocument)
return posts
})
}
interface PostsQuery {
posts: {
id: string
title: string
author?: {
id: string
firstName: string
lastName: string
}
}[]
}
In this way, you need to prepare the response type for useQuery in advance before fetching data.
The role of GraphQL Code Generator is to eliminate the need to write this type definition manually.
Create a configuration file codegen.ts and specify the schema and target GraphQL operations.
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: 'https://localhost:4000/graphql',
documents: ['src/**/*.tsx'],
generates: {
'./src/gql/': {
preset: 'client',
}
}
}
export default config
Next, run the following command to automatically generate type definitions:
npx graphql-codegen
Then you can fetch data as shown below:
import { request } from 'graphql-request'
import { useQuery } from '@tanstack/react-query'
import { graphql } from './gql/gql'
const postsQueryDocument = graphql(/* GraphQL */ `
query Posts {
posts {
id
title
author {
id
firstName
lastName
}
}
}
`)
const Posts = () => {
const { data } = useQuery<PostsQuery>('posts', async () => {
const { posts } = await request(endpoint, postsQueryDocument)
return posts
})
}
In this way, you can fetch data using types automatically generated from the schema definition without having to create response types yourself.
If you were separately creating both the GraphQL schema definition and the response (TypeScript) type definitions, you would need to modify both whenever the definitions change.
There is also a risk that a developer who is not fully aware of the situation might update only one of the two definitions, causing inconsistencies and potentially leading to data-fetching failures.
From this perspective as well, it is desirable to manage them centrally.
Schema-Driven Development (Establishing a GraphQL SDL-Based Development Flow)
What is Schema-Driven Development (SDD)?
Schema-Driven Development (SDD) is a development approach in which API specifications are designed and implemented based on GraphQL schema definitions (SDL: Schema Definition Language).
With traditional REST APIs, you needed to create a separate API specification document and share it between the frontend and backend. However, in GraphQL, the schema itself becomes the API specification, which makes the development process smoother.
Benefits
- Clarified specifications
Since the schema itself is the API specification, discrepancies in understanding of the spec between frontend and backend are less likely to occur. - Automatic documentation
By using tools such as GraphQL Playground or Apollo Studio, you can automatically generate documentation from schema definitions, making it easier for API consumers to understand. - Easy detection of changes
When there are changes to the schema, consistency checks with the implementation are more likely to occur automatically, making the impact of changes clearer.
By following this step, you can improve the overall team’s work efficiency and achieve smooth communication and project management.
Development Flow
Create Schema Definitions
type Author {
id: Int!
firstName: String!
lastName: String!
}
type Post {
id: Int!
title: String!
author: Author
}
type Query {
posts: [Post] # Retrieve multiple posts
post(id: ID!): Post # Retrieve a single post
}
This schema functions as the base of the API and becomes the shared understanding between the frontend and backend.
4. Backend Implementation
Implement backend resolvers using TypeScript types generated from the schema.
Benefits
- Since the schema becomes the API specification, the development direction does not drift
- You can ensure that the implementation conforms to the schema
Resolver definition
// Dummy data
const posts = [
{ id: 1, title: "GraphQL Introduction", authorId: 1 },
{ id: 2, title: "Advanced GraphQL", authorId: 1 },
{ id: 3, title: "GraphQL with Apollo", authorId: 2 },
];
export const resolvers = {
Query: {
// Retrieve all posts
posts: () => posts,
// Retrieve a specific post by ID
post: (_parent: any, args: { id: number }) =>
posts.find((post) => post.id === args.id),
},
};
Parse the GraphQL schema definition as gql (GraphQL tag).
import { readFileSync } from "fs";
import { gql } from "graphql-tag";
import path from "path";
const schemaPath = path.resolve(process.cwd(), "../packages/graphql/schema/post.graphql");
const typeDefs = gql(readFileSync(schemaPath, "utf-8"));
export { typeDefs };
Finally, integrate the GraphQL server into Express and start it.
import express from "express";
import cors from "cors";
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { resolvers } from './graphql/resolvers';
import { typeDefs } from "./graphql/type";
const server = new ApolloServer({ typeDefs, resolvers });
const app = express();
const PORT = process.env.PORT || 4000;
const startServer = async () => {
await server.start();
app.use(
'/graphql',
cors(),
express.json(),
expressMiddleware(server)
);
app.listen(PORT, () => {
console.log('GraphQL server is running at http://localhost:4000/graphql');
});
};
startServer();
Automatically Generate TypeScript Types from the Schema
By using GraphQL Code Generator to automatically generate TypeScript types from the GraphQL schema, you can achieve type-safe development.
Benefits
- When the API changes, type errors occur, preventing missed fixes
- Client-side development becomes safer
Create a configuration file for GraphQL Code Generator (@graphql-codegen/cli) and configure it to automatically generate typed TypeScript code from the GraphQL schema and queries.
import { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: '../packages/graphql/schema/**/*.graphql',
generates: {
"generated/graphql.ts": {
plugins: [
'typescript',
'typescript-operations',
],
},
}
};
export default config;
TypeScript types are generated.
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string; }
String: { input: string; output: string; }
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
};
export type Author = {
__typename?: 'Author';
firstName: Scalars['String']['output'];
id: Scalars['Int']['output'];
lastName: Scalars['String']['output'];
posts?: Maybe<Array<Maybe<Post>>>;
};
export type AuthorPostsArgs = {
findTitle?: InputMaybe<Scalars['String']['input']>;
};
export type Post = {
__typename?: 'Post';
author?: Maybe<Author>;
id: Scalars['Int']['output'];
title: Scalars['String']['output'];
};
export type Query = {
__typename?: 'Query';
post?: Maybe<Post>;
posts?: Maybe<Array<Maybe<Post>>>;
};
export type QueryPostArgs = {
id: Scalars['ID']['input'];
};
Frontend Implementation
Implement GraphQL queries on the frontend using TypeScript types generated from the schema.
Benefits
- Prevents discrepancies between API specifications and implementation
- Since schema changes are reflected in types, they are easily detected as type errors
First, create a GraphQL query.
query GetPosts {
posts {
id
title
author {
firstName
lastName
}
}
}
Change the Code Generator config settings.
import { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: '../packages/graphql/schema/**/*.graphql',
documents: '../packages/graphql/operations/**/*.graphql',
generates: {
"generated/graphql.ts": {
plugins: [
'typescript',
'typescript-operations',
'typescript-graphql-request',
],
},
}
};
export default config;
Then new definitions and return types related to the query are generated and can be used on the frontend.
export type GetPostsQuery = {
__typename?: 'Query',
posts?: Array<{
__typename?: 'Post',
id: number,
title: string,
author?: {
__typename?: 'Author',
firstName: string,
lastName: string
} | null
} | null> | null
};
export const GetPostsDocument = gql`
query GetPosts {
posts {
id
title
author {
firstName
lastName
}
}
}
`;
export type SdkFunctionWrapper = <T>(action: (requestHeaders?:Record<string, string>) => Promise<T>, operationName: string, operationType?: string, variables?: any) => Promise<T>;
const defaultWrapper: SdkFunctionWrapper = (action, _operationName, _operationType, _variables) => action();
export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
return {
GetPosts(variables?: GetPostsQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise<GetPostsQuery> {
return withWrapper((wrappedRequestHeaders) => client.request<GetPostsQuery>(GetPostsDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'GetPosts', 'query', variables);
}
};
}
export type Sdk = ReturnType<typeof getSdk>;
import { GraphQLClient } from "graphql-request";
import { getSdk } from "../generated/graphql";
const client = new GraphQLClient("http://localhost:4000/graphql");
const sdk = getSdk(client);
export async function getPost() {
const response = await sdk.GetPosts();
return response.posts
}
import { useEffect, useState } from "react";
import { GetPostsQuery } from "../generated/graphql";
import { getPosts } from "./service";
function App() {
const [posts, setPosts] = useState<GetPostsQuery["posts"] | null>(null);
useEffect(() => {
getPosts()
.then((data) => {
setPosts(data);
})
.catch((error) => {
console.error(error);
});
}, []);
return (
<div>
<h1>React + Express</h1>
{posts === null || posts === undefined ? (
<p>Loading...</p>
) : posts.length === 0 ? (
<p>No posts found.</p>
) : (
<ul>
{posts.map((post) => (
<li key={post?.id}>
<h2>{post?.title}</h2>
<p>Author: {post?.author?.firstName} {post?.author?.lastName}</p>
</li>
))}
</ul>
)}
</div>
);
}
export default App;
GraphQL Schema Management (Tracking Change History and Versioning)
A GraphQL schema is like a contract that defines communication between the frontend and backend. Therefore, when the schema changes, the consistency between the two can be broken.
In particular, when there are breaking changes, there is a risk that client-side code will stop working, so schema versioning and tracking change history are important.
Versioning Schemas Using Git
By managing schema files in a Git repository and tracking change history, you can see when and how changes were made.
Also, on GitHub, you can use GitHub Actions to automatically add labels to PRs when there are changes to schema.graphql.
1. Create .github/labeler.yml (Configuration to Detect Schema Changes)
schema-change:
- "apps/packages/graphql/schema/**/*.graphql"
- Detects only changes to schema files.
2. Create a GitHub Actions Workflow (Add Labels to PRs) .github/workflows/label-schema-changes.yml
name: "Add label for GraphQL schema changes"
on:
pull_request:
permissions:
pull-requests: write
contents: read
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Apply labels
uses: actions/labeler@v4
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
- When there are changes under
apps/packages/graphql/schema/**/*.graphql, the labelschema-changeis automatically added to the PR.
When you create a PR:
- ① The GitHub Actions workflow that adds labels is executed
- ②
schema-changeis set in Labels.
In addition to notifying via labels, you can also send notifications by using Slack integration in GitHub Actions workflows.
Schema Migration
Instead of changing the GraphQL schema directly, you can minimize the impact on clients by performing a gradual migration.
Flow for safe schema changes
- Prioritize non-breaking (backward compatible) changes
- Adding fields
- Changing default values
- When breaking changes are necessary, use deprecation
- Use the
@deprecateddirective to deprecate old fields - Remove them after the client-side changes are complete
- Use the
Example:
type Post {
id: Int!
title: String!
description: String @deprecated(reason: "Use 'excerpt' instead.")
author: Author
}
By adding @deprecated like this, you can notify clients that “this field will be removed in the future.”
When creating queries in GraphQL Playground, it also shows that the field is deprecated, allowing you to inform developers.
Backend Response Validation (Using Zod, etc.)
Backend response validation is the process of verifying that the data returned from the API to the client has the expected format and structure. Proper validation helps prevent errors on the frontend and strengthens security.
Why Response Validation Is Necessary
-
Ensuring data consistency
Because the backend may return unexpected data, validation is needed to prevent the frontend from malfunctioning. -
Reducing security risks
If invalid data is included in the response, security risks such as XSS and data tampering can occur. -
Early bug detection
If the response structure changes due to API changes, validation can immediately detect the problem.
How to Implement Response Validation
- Introduce schema validation
It is common to define the response structure using JSON Schema or similar and perform validation based on it.
Example: Validation using Zod (Node.js)
import { z } from 'zod';
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
const validateResponse = (data: any) => {
const result = userSchema.safeParse(data);
if (!result.success) {
console.error("Invalid response data:", result.error.format());
throw new Error("Response validation failed");
}
return result.data;
};
- Implement validation on the backend
When generating backend responses, check data types and values to avoid returning invalid data.
Example of response validation in Express
import express from 'express';
import { z } from 'zod';
const app = express();
const responseSchema = z.object({
success: z.boolean(),
data: z.object({
id: z.number(),
name: z.string(),
}),
});
app.get('/user', (req, res) => {
const responseData = {
success: true,
data: { id: 1, name: "Taro" },
};
const validationResult = responseSchema.safeParse(responseData);
if (!validationResult.success) {
return res.status(500).json({ error: "Invalid response format" });
}
res.json(responseData);
});
app.listen(3000, () => console.log("Server running on port 3000"));
Strengthening Type Checks When Fetching (Preventing Inconsistencies on the Frontend)
In frontend development, if the type of data obtained from the API is not as expected, the behavior of the application can become unstable. This article explains how to strengthen type checks when fetching and prevent inconsistencies on the frontend.
Type Definitions for API Responses
By using TypeScript to define the types of data returned from the API, you can develop in a type-safe manner.
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(): Promise<User> {
const response = await fetch("https://api.example.com/user");
const data = await response.json();
return data; // Add validation to strengthen type checking
}
Avoid Type Assertions
If you overuse type assertions (as Type), you may bypass compile-time errors even when inconsistent data is actually passed, preventing proper type checking from working.
async function fetchUser(): Promise<User> {
const response = await fetch("https://api.example.com/user");
const data = await response.json();
return data as User; // ⚠️ With only type assertion, invalid data can still slip through
}
Type-Safe Data Checking with Zod
By using schema validation libraries such as Zod, you can strictly validate the structure of API responses.
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(): Promise<User> {
const response = await fetch("https://api.example.com/user");
const data = await response.json();
return UserSchema.parse(data); // Perform type checking here
}
Manual Checks Using Type Guards
You can also manually perform type checks without using a validation library.
function isUser(data: unknown): data is User {
return (
typeof data === "object" && data !== null &&
"id" in data && typeof (data as any).id === "number" &&
"name" in data && typeof (data as any).name === "string" &&
"email" in data && typeof (data as any).email === "string"
);
}
async function fetchUser(): Promise<User> {
const response = await fetch("https://api.example.com/user");
const data = await response.json();
if (!isUser(data)) {
throw new Error("Invalid API response");
}
return data;
}
Type-Safe Development Using GraphQL Code Generator
import { gql } from "graphql-tag";
import { request } from "graphql-request";
import { UserQuery } from "@generated/graphql";
const GET_USER = gql`
query getUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
async function fetchUser(id: string): Promise<UserQuery> {
const response = await request("/graphql", GET_USER, { id });
return response;
}
By applying GraphQL types in this way, you can guarantee the types of API responses.
Automatic API Documentation Generation
In API development, documentation maintenance and test automation are important elements. This section explains automatic API documentation generation using GraphQL Playground and Swagger, and API test automation using mock servers.
Using GraphQL Playground for API Documentation
In GraphQL, you can dynamically generate API documentation based on schema definitions. GraphQL Playground is a convenient tool that allows you to visually check API endpoints and instantly document schemas.
- Enable Playground with the following configuration:
import { ApolloServer, gql } from 'apollo-server';
const typeDefs = gql`
type Query {
hello: String
}
`;
const resolvers = {
Query: {
hello: () => 'Hello, world!',
},
};
const server = new ApolloServer({ typeDefs, resolvers });
server.listen().then(({ url }) => {
console.log(`Server ready at ${url}`);
});
-
When you start the server, you can access Playground at
http://localhost:4000. -
You can immediately reflect changes to the GraphQL schema and automatically generate developer-facing API documentation.
Field information is displayed as shown below, and code completion is available when creating sample queries.
REST API Documentation Using Swagger
For automatic REST API documentation generation, use Swagger (OpenAPI).
- Add
swagger-jsdocandswagger-ui-expressto your Express-based API.
npm install swagger-jsdoc swagger-ui-express
- Add Swagger configuration.
import express from 'express';
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
const app = express();
const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'Sample API',
version: '1.0.0',
},
},
apis: ['./routes/*.js'],
};
const swaggerDocs = swaggerJsdoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocs));
app.listen(3000, () => console.log('Server running on port 3000'));
- Access
http://localhost:3000/api-docsto view the API documentation in Swagger UI.
API Test Automation (Using Mock Servers)
To efficiently test APIs, use mock servers. With tools like json-server and GraphQL’s @graphql-tools/mock, you can proceed with frontend development and testing even before backend development is complete.
Mock REST API Using JSON Server
json-server is a convenient tool that provides a REST API based on a simple JSON file. This allows you to proceed with frontend development and testing even if the backend implementation is not yet complete.
-
Install
json-server.npm install -g json-server -
Create
db.jsonand prepare dummy data.{ "users": [ { "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" } ] } -
Start
json-server.json-server --watch db.json --port 3001 -
Access
http://localhost:3001/usersto confirm that the mock API is running.
json-server also has the following customization features:
-
Custom route configuration: Create
routes.jsonto customize endpoints.{ "/api/users": "/users" } -
This is for customizing
json-serverlikeExpress. Normally,json-serveris easy to use from the CLI, but by extending it likeExpress, you can handle requests more flexibly.
const jsonServer = require('json-server');
const server = jsonServer.create(); // Create an instance of JSON Server
const router = jsonServer.router('db.json'); // Generate routing based on db.json
const middlewares = jsonServer.defaults(); // Apply JSON Server default middleware (CORS, logging, JSON parsing, etc.)
server.use(middlewares); // Apply middleware
server.use((req, res, next) => {
console.log('Request received:', req.method, req.url); // Output request logs
next(); // Pass processing to the next middleware
});
server.use(router); // Apply router
server.listen(3001, () => {
console.log('JSON Server is running on port 3001');
});
Key points
- By outputting all requests to the console, debugging becomes easier.
- In addition to the standard
json-serverlogs, you can control request details more precisely.
server.use('/users', (req, res, next) => {
req.query.limit = 10; // Forcefully add a query parameter
next();
});
Key points
- You can also modify request data when accessing
/users.
router.render = (req, res) => {
res.json({
success: true,
data: res.locals.data
});
};
- You can change the default
json-serverresponse to return a custom response. - You can add
success: trueto all responses.
Building a GraphQL Mock Server
- Use
@graphql-tools/mockto build a GraphQL mock server.
npm install @graphql-tools/mock @graphql-tools/schema graphql-tag
- Add mock API configuration.
import { makeExecutableSchema } from '@graphql-tools/schema';
import { addMocksToSchema } from '@graphql-tools/mock';
import { graphql } from 'graphql';
const typeDefs = `
type User {
id: ID!
name: String!
}
type Query {
users: [User]
}
`;
const schema = makeExecutableSchema({ typeDefs });
const mockedSchema = addMocksToSchema({ schema });
graphql(mockedSchema, '{ users { id name } }').then((result) =>
console.log(JSON.stringify(result, null, 2))
);
- A mock API based on the schema is immediately generated and can be tested.
Conclusion
By using GraphQL Code Generator, you can streamline GraphQL schema management and automatic type definition generation, and prevent type mismatches between the backend and frontend. In addition, response validation with Zod and strengthened type checks when fetching enable safe data exchange. Furthermore, automatic API documentation generation and API test automation using mock servers can improve development efficiency.
By applying the methods introduced in this article, you can achieve type-safe API development that fully leverages the strengths of GraphQL. Try applying them to your projects to aim for a smoother development experience.
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
Complete Guide to Web Accessibility: From Automated Testing with Lighthouse / axe and Defining WCAG Criteria to Keyboard Operation and Screen Reader Support
2023/11/21Robust Authorization Design for GraphQL and REST APIs: Best Practices for RBAC, ABAC, and OAuth 2.0
2024/05/13Introduction to Automating Development Work: A Complete Guide to ETL (Python), Bots (Slack/Discord), CI/CD (GitHub Actions), and Monitoring (Sentry/Datadog)
2024/02/12Complete Cache Strategy Guide: Maximizing Performance with CDN, Redis, and API Optimization
2024/03/07CI/CD Strategies to Accelerate and Automate Your Development Flow: Leveraging Caching, Parallel Execution, and AI Reviews
2024/03/12Strengthening Dependency Security: Best Practices for Vulnerability Scanning, Automatic Updates, and OSS License Management
2024/01/29How to Easily Build a Web API with Express and MongoDB [TypeScript Compatible]
2024/12/09Express (+ TypeScript) Beginner’s Guide: How to Quickly Build Web Applications
2024/12/07


