课程
针对 GraphQL API 手写的 TypeScript 类型,一旦有人修改了模式,就会立刻过时。
设想一下,后端开发者把 Course 类型上的 title 字段重命名为 name。前端仍然可以编译通过,因为手写的 Course 接口并不知道发生了改变,结果只有在运行时才暴露出 bug。真实世界的应用通常有数十个查询、变更和片段,问题升级会非常快。
GraphQL Code Generator(简称 GraphQL Codegen)会读取您的模式和 GraphQL 操作,并将它们转换为类型、查询辅助函数、客户端集成以及其他原本需要手写的样板代码。它最常与 TypeScript 和 Apollo、urql 等主流客户端一起使用。每当模式发生变化,一个命令就能让您的类型重新保持同步。
本文将带您在一个小型 TypeScript 项目中从零开始设置 GraphQL Codegen,进行配置、使用生成的类型,并将其自动化到您的工作流程中。
GraphQL 与 REST 有何不同?阅读我们的《Graph QL vs REST:完整指南》了解差异与优势。
什么是 GraphQL Codegen?
GraphQL Codegen 是一个命令行工具,可将 GraphQL 定义转换为应用代码。
它是 The Guild 的一个开源项目,您将其作为开发依赖安装。它连接三样东西:
- GraphQL 模式: 服务器与客户端之间的契约,包含 API 暴露的每个类型、字段和参数
- GraphQL 操作: 您的应用发送给 API 的查询、变更、订阅和片段
- 生成的代码: Codegen 基于模式和您的操作写出的类型和辅助工具
模式告诉 Codegen 能做什么,操作告诉它您的应用需要什么。
Codegen 将两者结合,因此每个生成的类型都与某个操作的响应结构完全匹配。它还会将每个操作与模式进行校验。前文提到将 title 重命名的例子,会令生成过程报错,例如 Cannot query field "title" on type "Course",您在代码运行前就能发现问题。
输出取决于插件和配置。Codegen 本身不直接写 TypeScript,插件才会。例如,typescript 插件会生成模式类型,而 typescript-operations 插件会为您的查询和变更生成类型。还有适用于其他语言的插件,如 Java 和 C#,但本文仅讨论 TypeScript。
需要记住的一点是,Codegen 并不会替代您的 GraphQL 服务器。
它不会解析查询或获取数据。它在开发期间读取 GraphQL 定义并写出文件。剩下的工作由您的服务器和 GraphQL 客户端完成。
GraphQL Codegen 的工作原理
每个 Codegen 的设置都遵循相同流程: 模式 + GraphQL 操作 -> Codegen 配置 -> 生成的代码。

GraphQL Codegen 工作流示意图
大致思路如下:
-
提供模式: 将 Codegen 指向本地
.graphql文件或正在运行的 GraphQL 端点,Codegen 会通过自省读取 -
指向您的操作: 使用
src/**/*.ts这样的 glob 模式,使 Codegen 能在代码中找到查询、变更和片段 -
配置插件和输出目标: 告诉 Codegen 要写哪些文件,以及使用哪些插件来生成
-
运行 Codegen: 运行
npx graphql-codegen以加载模式、校验操作并写出结果 -
使用生成的产物: 在应用代码中导入生成的类型和文档节点
第 1 到 3 步会放到一个配置文件中,通常是 codegen.ts。下面是课程目录项目的最小示例:
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
// Step 1: Where the schema comes from
schema: "schema.graphql",
// Step 2: Where your operations are placed
documents: ["src/**/*.ts"],
// Step 3: What to generate and where to write it
generates: {
"./src/gql/": { preset: "client" },
},
};
export default config;
client 预设是一个插件组合,可以一次性生成模式类型、操作类型和带类型的文档节点。稍后我会在搭建和配置部分分别介绍这些内容。
如何设置 GraphQL Codegen
理解 Codegen 的最佳方式是将其跑在一个真实项目上。
本节将从零开始构建一个小型课程目录项目。它包含课程和讲师的模式、一个查询,以及一个能将查询转换为 TypeScript 类型的 Codegen 配置。您只需要 Node.js 和 npm。
步骤 1:安装 GraphQL Code Generator
创建一个新文件夹并初始化 npm 项目:
mkdir course-catalog && cd course-catalog
npm init -y
Codegen 需要几个包。两个放进常规依赖,其余作为开发依赖,因为它们只在开发期间运行:
npm install graphql @graphql-typed-document-node/core
npm install -D typescript @graphql-codegen/cli @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node
每个包的作用如下:
-
graphql: JavaScript 的 GraphQL 参考实现,Codegen 用它解析模式和操作 -
@graphql-typed-document-node/core: 提供生成代码会导入的TypedDocumentNode类型 -
@graphql-codegen/cli: Codegen 命令行工具 -
@graphql-codegen/typescript-operations: 为查询、变更和片段生成类型的插件 -
@graphql-codegen/typed-document-node: 生成带类型的文档对象的插件
步骤 2:创建配置文件
Codegen 会从项目根目录的配置文件读取设置。先创建一个空配置的 codegen.ts:
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {};
export default config;
您也可以运行 npx graphql-codegen init 来回答几个问题并获得一个生成的配置文件。这里手写是为了让您了解每个设置的作用。
步骤 3:指定模式与文档位置
Codegen 需要两个输入,我们来分别创建。
首先,将模式保存为项目根目录下的 schema.graphql:
enum Level {
BEGINNER
INTERMEDIATE
ADVANCED
}
type Instructor {
id: ID!
name: String!
bio: String
}
type Course {
id: ID!
title: String!
level: Level!
durationHours: Int!
instructor: Instructor!
}
type Enrollment {
id: ID!
course: Course!
studentEmail: String!
}
type Query {
courses(level: Level, limit: Int): [Course!]!
course(id: ID!): Course
}
type Mutation {
enrollStudent(courseId: ID!, studentEmail: String!): Enrollment!
}
然后,新建 src/operations/courses.graphql,写一个获取课程及其讲师的查询:
query GetCourses($level: Level, $limit: Int) {
courses(level: $level, limit: $limit) {
id
title
level
instructor {
name
}
}
}
现在把两者都指给 Codegen。schema 键接收模式路径,documents 接收能匹配操作的 glob 模式:
const config: CodegenConfig = {
schema: "schema.graphql",
documents: ["src/**/*.graphql"],
};
步骤 4:选择输出文件
generates 键告诉 Codegen 要把输出写到哪里。它内部的每个键都是一个文件路径:
const config: CodegenConfig = {
schema: "schema.graphql",
documents: ["src/**/*.graphql"],
generates: {
"src/generated/graphql.ts": {},
},
};
如果不存在,Codegen 会创建 src/generated/ 文件夹。
步骤 5:添加相关插件
没有插件的输出文件是空的。添加您安装的两个插件:
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "schema.graphql",
documents: ["src/**/*.graphql"],
generates: {
"src/generated/graphql.ts": {
plugins: ["typescript-operations", "typed-document-node"],
},
},
};
export default config;
如果您看过较早的教程,可能也会在列表中看到 typescript 插件。现在可以移除它,因为 typescript-operations 可以单独工作,并且只生成实际使用到的输入、枚举和操作类型。如果在当前版本将两者放在同一个输出文件中,TypeScript 会抛出重复声明错误。
步骤 6:运行 Codegen
添加一个 npm 脚本,避免每次都记命令:
npm pkg set scripts.codegen="graphql-codegen"
然后运行:
npm run codegen
Codegen 会加载模式、根据它校验查询并写出输出:

Codegen 生成结果
如果某个操作引用的字段在模式中不存在,运行会在这里失败,并且不会写出任何文件。
步骤 7:检查生成的输出
打开 src/generated/graphql.ts。您会看到:

生成的输出
Codegen 生成了四样东西:
-
Level: 枚举对应的字符串联合类型,仅在查询使用到时才会生成 -
GetCoursesQueryVariables: 查询变量的类型,由于模式未标记为必填,因此二者都是可选的 -
GetCoursesQuery: 响应的形状,只包含查询请求的字段 -
GetCoursesDocument: 解析后的查询,作为带类型的文档节点,附带结果和变量类型
再看 GetCoursesQuery。它不包含 durationHours 或讲师的 bio,因为查询没有请求它们。如果您尝试在代码中读取 course.durationHours,会得到编译错误,而不是运行时的 undefined。
这就是全部搭建流程——您写 GraphQL,Codegen 写 TypeScript。
如何配置 GraphQL Codegen
大多数您见到的 Codegen 配置都比上面的更长,但它们都由相同的四个设置构成。
配置文件可以是 codegen.ts、codegen.yml 或 codegen.json。我使用 TypeScript,因为它能为配置本身提供自动补全和类型检查。不同格式下设置相同。
Schema(模式)
schema 键告诉 Codegen 模式的位置。它可以接受多种来源。
当模式在您的仓库中时,可以使用本地文件或 glob 模式:
schema: "schema.graphql",
// or, for a schema split across files
schema: "schema/**/*.graphql",
当模式在正在运行的服务器上时,可以使用 URL。Codegen 会向端点发送自省查询,您可以传入鉴权头:
schema: [
{
"https://api.example.com/graphql": {
headers: { Authorization: Bearer ${process.env.API_TOKEN} },
},
},
],
URL 选项只在服务器启用自省时有效。许多生产 API 会关闭它,因此在 CI 中,提交一份模式文件到仓库通常更稳妥。
Documents(文档)
documents 键告诉 Codegen 去哪里找查询、变更、订阅和片段。
操作可以放在 .graphql 文件中,也可以内联在 TypeScript 代码中。若内联,Codegen 会在 gql 标签包裹或添加 /* GraphQL */ 注释时发现它们:
const EnrollStudent = /* GraphQL */
mutation EnrollStudent($courseId: ID!, $studentEmail: String!) {
enrollStudent(courseId: $courseId, studentEmail: $studentEmail) {
id
course {
title
}
}
}
;
要同时扫描两种文件类型,扩大 glob。再加一个否定模式,避免 Codegen 扫描自己的输出:
documents: ["src/**/*.{graphql,ts}", "!src/generated/**"],
请为每个操作起唯一名称。Codegen 会基于操作名构造类型名,比如 EnrollStudent 会变成 EnrollStudentMutation 和 EnrollStudentMutationVariables。
Generates(输出)
generates 键将输出路径映射到其中的内容。您可以按需添加多个输出目标,每个都有自己的插件。
比如,您想把操作类型放在一个文件里,把完整模式类型放在另一个文件里:
generates: {
"src/generated/graphql.ts": {
plugins: ["typescript-operations", "typed-document-node"],
config: {
useTypeImports: true,
},
},
"src/generated/schema-types.ts": {
plugins: ["typescript"],
},
},
config 对象位于某个输出目标内时,仅对该文件生效。这里的 useTypeImports 会让生成文件使用 import type,某些构建环境需要它。如果将 config 放在文件根部,则会应用于每个输出。
输出路径也可以是以斜杠结尾的文件夹,如 "src/gql/"。这是预设常用的格式,因为它们会写多个文件。
Plugins(插件)
由插件决定生成什么。Codegen 加载模式和文档,每个插件将它们转换为某一部分输出。
在 TypeScript 项目中,您最常见到的插件有:
-
typescript-operations: 为查询、变更、订阅与片段生成类型,以及它们使用到的枚举和输入类型 -
typed-document-node: 生成附带结果与变量类型的文档对象,多数 GraphQL 客户端都能识别 -
typescript: 为模式中的每个对象、枚举和输入生成类型,无论您的操作是否使用它们 -
typescript-resolvers: 服务器端解析器函数的类型签名
一个预设是带有合理默认配置的插件集合。工作原理一节中提到的 client 预设是 The Guild 推荐给客户端应用的方案。它在底层基于改进的 typescript-operations,因此无需额外设置即可获得其优势。
每个插件都有自己的配置选项,数量不少。您不必预先掌握所有选项。先用默认值,当生成的输出不符合代码预期时再查找对应选项。
GraphQL Codegen 能生成什么?
Codegen 的产物取决于您选择的插件。
相同的模式与操作可以生成纯 TypeScript 类型,也可以生成现成的客户端代码。以下是您最常用的输出。
TypeScript 类型
typescript 插件会为模式中的每个对象、枚举和输入生成类型。对于课程目录,Course 类型大致如下:
export type Course = {
__typename?: 'Course';
durationHours: Scalars['Int']['output'];
id: Scalars['ID']['output'];
instructor: Instructor;
level: Level;
title: Scalars['String']['output'];
};
这些类型描述的是完整模式,因此在服务器端、测试夹具以及处理完整对象的工具函数中很实用。
但对客户端代码而言并不合适。Course 类型表示每个字段都存在,即使您的查询从未请求它们。
操作类型
typescript-operations 插件会为每个操作生成两个类型:一个用于结果,一个用于变量。对查询和订阅同样适用。
以下是对 EnrollStudent 变更生成的内容:
export type EnrollStudentMutationVariables = Exact<{
courseId: string | number;
studentEmail: string;
}>;
export type EnrollStudentMutation = { enrollStudent: { id: string, course: { title: string } } };
结果类型与选择集逐字段匹配。变量类型为 Exact,因此 TypeScript 会拒绝多余或拼写错误的变量。
courseId 变量接受 string 和 number,因为 GraphQL 的 ID 标量在输入端接受两者。自 v6 起,默认类型为 string | number,这对客户端用例是正确的。
带类型的文档节点
普通的 DocumentNode 是解析过的 GraphQL 查询。它了解查询的结构,但不知道查询返回什么或需要哪些变量。
一个带类型的文档节点同时拥有这两种类型。typed-document-node 插件会为每个操作生成一个:
export const GetCoursesDocument = { /* ... */ } as unknown as DocumentNode<GetCoursesQuery, GetCoursesQueryVariables>;
这样,任何接受 TypedDocumentNode 的函数都能仅凭文档推断结果和变量类型。您无需手动传入泛型,也无法把查询与错误的类型组合起来。
大多数现代 GraphQL 客户端都支持带类型的文档节点,包括 Apollo Client、urql 和 graphql-request。稍后您会看到三者的实际用法。
特定客户端的集成
一些插件更进一步,为特定客户端或框架生成代码。例如:
-
typescript-react-apollo: 为 Apollo Client 生成带类型的 React hooks -
typescript-urql: 为 urql 生成带类型的 hooks 和组件 -
typescript-react-query: 为 TanStack Query 生成带类型的 hooks -
typescript-graphql-request: 生成一个带类型的 SDK,每个操作对应一个函数
以上都是社区插件。The Guild 已将它们迁移到单独的社区仓库,因此其发版节奏与核心插件不同。
它们的必要性也比过去更低。只要客户端支持带类型的文档节点,生成的 hooks 对类型安全的提升并不大。例如 Apollo 的官方文档就推荐使用 typescript-operations 插件,它专注只生成类型,不包含额外的运行时代码。
通常,您应先从操作类型和带类型的文档节点开始。只有当团队确实需要生成的 hooks 或 SDK 函数时,再添加特定客户端的插件。
结合 TypeScript 使用 GraphQL Codegen
生成的类型只有在能捕捉错误时才有价值,那么就来故意犯些错。
这个示例在课程目录项目中添加第二个查询、一个将其发送到 API 的小型辅助函数,以及使用结果的代码。它假设实现了该模式的 GraphQL 服务器运行在 http://localhost:4000/graphql。
以下是本示例用到的模式部分:
type Instructor {
id: ID!
name: String!
bio: String
}
type Course {
id: ID!
title: String!
level: Level!
durationHours: Int!
instructor: Instructor!
}
type Query {
course(id: ID!): Course
}
这里有两个关键信息。course 字段返回的 Course 没有 !,因此当没有匹配 ID 的课程时它为 null。讲师的 bio 也可为 null。
创建 src/operations/course.graphql,写一个获取单个课程的查询:
query GetCourse($id: ID!) {
course(id: $id) {
title
durationHours
instructor {
name
bio
}
}
}
再次运行 npm run codegen。Codegen 会在 src/generated/graphql.ts 中新增两个类型:

graphql.ts 文件的内容
模式中的可空性会被带入。course 可能为 null,bio 可能为 null,而 id 是必填的,因为查询声明它为 ID!。
现在编写应用代码。创建 src/execute.ts,写一个能将任意带类型文档发送到 API 的辅助函数:
import { print } from "graphql";
import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
const API_URL = "http://localhost:4000/graphql";
export async function execute<TResult, TVariables>(
document: TypedDocumentNode<TResult, TVariables>,
variables: TVariables,
): Promise<TResult> {
const response = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: print(document), variables }),
});
const { data, errors } = await response.json();
if (errors?.length) {
throw new Error(errors[0].message);
}
return data as TResult;
}
该辅助函数对课程一无所知。它从您传入的文档中获取两种类型,print() 函数会把文档重新转成请求所需的查询字符串。
然后在 src/course-details.ts 中使用它:
import { execute } from "./execute";
import { GetCourseDocument, type GetCourseQuery } from "./generated/graphql";
function formatCourse(course: NonNullable<GetCourseQuery["course"]>): string {
const bio = course.instructor.bio ?? "No bio yet";
return course.title({course.durationHours}h) by ${course.instructor.name} - ${bio};
}
const { course } = await execute(GetCourseDocument, { id: "c1" });
if (course) {
console.log(formatCourse(course));
} else {
console.log("Course not found");
}
formatCourse() 函数的参数类型来自生成的查询类型。NonNullable 会移除 null 情况,因为该函数只会在 if 判断之后运行。
示例使用了顶层 await,因此需要在 package.json 中设置 "type": "module",并用 npx tsx src/course-details.ts 运行:

成功调用 GraphQL 模拟服务器
这里假设您已有一个 GraphQL 服务器在运行。以下是我使用的一个模拟服务器:
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { createSchema, createYoga } from "graphql-yoga";
const instructors = [{ id: "i1", name: "Dario Radecic", bio: "Developer" }];
const courses = [
{ id: "c1", title: "Intro to GraphQL", level: "BEGINNER", durationHours: 4, instructor: instructors[0] },
{ id: "c2", title: "GraphQL Schema Design", level: "ADVANCED", durationHours: 6, instructor: instructors[0] },
];
const schema = createSchema({
typeDefs: readFileSync("schema.graphql", "utf8"),
resolvers: {
Query: {
courses: (_: unknown, args: { level?: string; limit?: number }) =>
courses
.filter((course) => !args.level || course.level === args.level)
.slice(0, args.limit ?? undefined),
course: (_: unknown, args: { id: string }) =>
courses.find((course) => course.id === args.id) ?? null,
},
Mutation: {
enrollStudent: (_: unknown, args: { courseId: string; studentEmail: string }) => ({
id: "e1",
course: courses.find((course) => course.id === args.courseId),
studentEmail: args.studentEmail,
}),
},
},
});
createServer(createYoga({ schema })).listen(4000, () => {
console.log("Mock API running at http://localhost:4000/graphql");
});
现在试着把它搞坏。打开 src/course-details.ts,将 formatCourse() 函数以下的所有内容替换为三个常见的错误,手写类型经常会放过它们:
// Missing the required id variable
await execute(GetCourseDocument, {});
const { course } = await execute(GetCourseDocument, { id: "c1" });
// Reading a field the query doesn't request
console.log(course?.level);
// Forgetting that course can be null
console.log(course.title);
然后对项目进行类型检查:
npx tsc -p .
TypeScript 会在代码运行前捕捉到三处错误:

TypeScript 捕获的错误
这就是 Codegen 如何减少前端与 API 间的不匹配。类型来自与服务器相同的模式,因此不会描述不存在的字段,也不会遗漏服务器可能返回的 null。
并且这种保护并不会在第一次运行后停止。假设后端团队把 durationHours 改为可空。您重新生成后,类型会变为 number | null,TypeScript 会指出所有曾假设它为 number 的地方。
将 GraphQL Codegen 与 GraphQL 客户端结合
Codegen 本身不发送请求。这是 GraphQL 客户端的职责。
生成的带类型文档节点是二者之间的交接点。您将它们传给客户端,客户端据此推断类型。下面示例都使用相同的 src/generated/graphql.ts 文件和上一节的模拟 API,因此请在单独的终端中保持 npx tsx mock/server.ts 运行。
与 Apollo 搭配的 GraphQL Codegen
安装 Apollo Client 及其 rxjs 同级依赖:
npm install @apollo/client rxjs
示例还会运行 EnrollStudent 变更。将其保存为 src/operations/enroll.graphql:
mutation EnrollStudent($courseId: ID!, $studentEmail: String!) {
enrollStudent(courseId: $courseId, studentEmail: $studentEmail) {
id
course {
title
}
}
}
Apollo 的文档还推荐在 Codegen 中开启两个配置选项,因为 Apollo Client 总是包含 __typename 字段,但不会将它添加到根类型。将它们添加到 codegen.ts 的输出目标中:
"src/generated/graphql.ts": {
plugins: ["typescript-operations", "typed-document-node"],
config: {
nonOptionalTypename: true,
skipTypeNameForRoot: true,
},
},
启用这些选项后,生成的结果类型中每个对象都会有必填的 __typename 字段,比如 __typename: 'Course'。这与 Apollo 缓存返回的内容一致。
再次运行 npm run codegen 以检测到新的变更和配置。
Apollo Client 在 query() 和 mutate() 中接受带类型文档节点。将以下保存为 src/apollo.ts:
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { EnrollStudentDocument, GetCoursesDocument } from "./generated/graphql";
const client = new ApolloClient({
link: new HttpLink({ uri: "http://localhost:4000/graphql" }),
cache: new InMemoryCache(),
});
const { data } = await client.query({
query: GetCoursesDocument,
variables: { level: "BEGINNER", limit: 5 },
});
data?.courses.forEach((course) => console.log(course.title));
const result = await client.mutate({
mutation: EnrollStudentDocument,
variables: { courseId: "c1", studentEmail: "student@example.com" },
});
console.log(result.data?.enrollStudent.course.title);
data 对象的类型是 GetCoursesQuery | undefined,TypeScript 会将 variables 与 GetCoursesQueryVariables 进行校验。变更同样适用于 EnrollStudentMutation 及其变量。
使用 npx tsx src/apollo.ts 运行:

Apollo 输出
第一行来自查询(它是唯一的初级课程)。第二行来自变更,返回的是学生所选课程的标题。
React hooks(如 useQuery() 和 useMutation())也会以同样方式从带类型文档节点中推断类型。
与 urql 搭配的 GraphQL Codegen
安装 urql 的核心包:
npm install @urql/core
urql 天生支持带类型文档节点。将文档作为 query() 的第一个参数传入,在 src/urql.ts 中如下:
import { Client, cacheExchange, fetchExchange } from "@urql/core";
import { GetCoursesDocument } from "./generated/graphql";
const client = new Client({
url: "http://localhost:4000/graphql",
exchanges: [cacheExchange, fetchExchange],
});
const result = await client
.query(GetCoursesDocument, { level: "ADVANCED" })
.toPromise();
if (result.error) {
throw result.error;
}
result.data?.courses.forEach((course) => console.log(course.title));
第二个参数的类型是查询变量,result.data 的类型是查询结果。
使用 npx tsx src/urql.ts 运行:

Urql 输出
各框架绑定(如 React 中的 urql 包里的 useQuery())同样支持带类型文档节点。
与其他客户端搭配的 GraphQL Codegen
您不需要功能齐全的客户端也能得到带类型的结果。
graphql-request 是一个最小化的客户端,它发送单个请求并返回数据。使用以下命令安装:
npm install graphql-request
它与 Apollo 和 urql 一样,从带类型文档节点中推断类型。将以下保存为 src/graphql-request.ts:
import { request } from "graphql-request";
import { GetCourseDocument } from "./generated/graphql";
const { course } = await request(
"http://localhost:4000/graphql",
GetCourseDocument,
{ id: "c2" },
);
console.log(course?.title);
使用 npx tsx src/graphql-request.ts 运行:

GraphQL request 输出
同样的思路也适用于 TanStack Query。它本身不发送 GraphQL 请求,因此您可以在查询函数中调用 graphql-request 或上一节的 execute() 辅助函数,结果类型会传递到您的代码中。
如果您的客户端完全不支持带类型文档节点,仍可使用纯操作类型。导入 GetCourseQuery 和 GetCourseQueryVariables 并将它们作为泛型传入。虽然更手动,但类型仍源自模式。
如何自动运行 GraphQL Codegen
只有当生成的类型与当前模式和操作一致时,它们才有用。
如果在修改后忘记重新运行 Codegen,类型描述的将是已经不存在的代码。这与手写类型的漂移问题本质相同,只是多了一步。解决方案是把这一手动步骤从工作流中移除。
npm 脚本
先在 package.json 中放几个脚本,让团队中的每个人以相同方式运行 Codegen:
{
"scripts": {
"codegen": "graphql-codegen",
"codegen:watch": "graphql-codegen --watch",
"codegen:check": "graphql-codegen --check",
"prebuild": "npm run codegen",
"build": "tsc -p ."
}
}
本节其余部分会介绍每个脚本的用途。
监听模式
监听模式会在每次监控的文件发生变动时重跑 Codegen。它需要 @parcel/watcher 包,Codegen 不会替您安装:
npm install -D @parcel/watcher
npm run codegen:watch
如果跳过安装,Codegen 会运行一次并打印 Failed to import @parcel/watcher,而不会持续监听。
监听模式会追踪您的操作和本地模式文件。如果您在 course.graphql 中添加字段,新字段会在一秒后出现在 GetCourseQuery 中。如果您在 schema.graphql 中把 durationHours 改为可空,类型会变为 number | null,编辑器会标出所有曾假设它为 number 的位置。
在开发期间,让它在与开发服务器相邻的终端中持续运行。
在构建前生成
npm 会在 build 之前运行一个名为 prebuild 的脚本。借助上述脚本,npm run build 会先重新生成类型,然后才运行 TypeScript 编译器。
这样,即使有人在拉取更改后忘记运行 Codegen,构建也不会基于过时的类型。
CI/CD
--check 标志会以干跑模式运行 Codegen。它不会写任何文件,而是将“将要生成”的内容与磁盘上的内容进行比较,若有差异则以代码 1 退出。
以下是一个最小的 GitHub Actions 工作流示例:
name: CI
on: [push, pull_request]
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npm run codegen:check
- run: npm run build
codegen:check 这一步只在您将生成的文件提交到仓库时有意义。如果没有,请移除该步骤,因为 npm run build 会通过 prebuild 钩子重新生成所有内容。
在模式变化时再生成
操作的变化发生在您的仓库中,因此监听模式与 prebuild 就能覆盖它们。模式的变化更棘手,因为通常来自另一个团队。
如何处理取决于模式的位置:
- 同一仓库中的模式文件: 监听模式和 CI 都能发现更改,这是最易用的设置
- 来自 URL 的模式: Codegen 每次运行都会拉取最新版本,因此一旦服务器的模式不再与提交的输出匹配,CI 中的
codegen:check会立即失败 - 由其他仓库发布的模式: 将其在构建时复制或下载,然后对本地副本运行 Codegen
URL 选项有一个注意点。监听模式是基于文件变动触发的,因此它不会察觉远程模式的变化。您只能在下一次手动运行或 CI 任务中看到这些变化。
GraphQL Codegen 与手写类型对比
两种方式都能为 GraphQL 数据提供 TypeScript 类型,但仍有一些差异需要注意。
手写类型
使用手写类型时,您需要阅读模式并自行编写匹配的接口。
这样您拥有完全掌控力。您可以随意命名类型、把多个操作合并为一个共享类型,或者添加模式中没有的辅助类型。
但这是手工活。每个新增字段或重命名参数,都意味着您要手动找到并更新匹配的类型。而且没有人会在您遗漏时提醒您。
这就是“漂移”的根源。前文中的 Course 接口在后端重命名 title 后仍能编译,因为 TypeScript 只会将您的代码与您的类型进行校验,而不会与 API 对比。
生成的类型
生成的类型源自与服务器相同的模式,因此从设计上就与您的 GraphQL 定义保持同步。一次模式变更加一次 Codegen 运行就能同时更新所有受影响的类型。
您也会少写很多代码。课程目录项目有三个操作,但没有为它们手写任何类型。
此外,您几乎是“免费”地获得了以模式驱动的开发。您修改模式或操作,重新生成,编译器就会给出所有需要改动的地方列表。
但要记住,Codegen 只会反映您提供给它的内容。
如果模式把每个字段都标记为可空,那么生成的类型里就会充斥 | null 检查。如果某个操作获取了 40 个字段,而页面只需要 5 个,那么生成的类型也会有 40 个字段。
以下是差异的快速回顾:
| 手写类型 | 生成的类型 | |
|---|---|---|
| 维护 | 每次模式变更都需手动更新 | 每次变更后执行一次命令 |
| 模式漂移 | 可能且无声发生 | 在生成或编译时被捕获 |
| 样板代码 | 随操作数量增长 | 无需手写 |
| 自定义类型控制力 | 完全掌控 | 受限于插件配置选项 |
| 以模式驱动的开发 | 手动交叉核对 | 编译器展示所有受影响的代码行 |
Codegen 与手写类型对比
GraphQL Codegen 与 GraphQL 自省对比
两者常被混淆,因为 Codegen 经常使用自省。但它们解决的是不同问题。
自省(Introspection)是 GraphQL 规范中的一个特性。任何 GraphQL 服务器都可以通过 __schema 和 __type 等特殊字段回答关于自身模式的查询。结果是一个 JSON,描述了 API 暴露的每个类型、字段与参数。
通俗地说,自省回答的是“这个 API 长什么样?”的问题。
Codegen 回答的是“我的应用需要什么代码?”的问题。它获取模式信息和您的操作,并写出 TypeScript 类型和带类型的文档节点。
当您在 codegen.ts 中将 schema 设置为 URL 时,Codegen 会向服务器发送自省查询以获取模式。若使用本地 .graphql 文件,则不会发生自省。
Codegen 也可以生成自省结果作为输出。@graphql-codegen/introspection 插件会以自省 JSON 的形式写出模式,一些工具(如 urql 的 Graphcache)会用它实现基于模式的缓存。
| 自省 | GraphQL Codegen | |
|---|---|---|
| 是什么 | 内建的 GraphQL 查询系统 | 命令行工具与插件 |
| 输入 | 对运行中服务器的查询 | 模式与您的操作 |
| 输出 | 模式的 JSON 描述 | 类型、文档节点、客户端代码 |
| 何时运行 | 每当客户端向服务器请求 | 在开发、构建与 CI 期间 |
Codegen 与自省对比
常见的 GraphQL Codegen 问题
大多数 Codegen 错误可归结为以下四类。
缺少生成的类型
Codegen 运行没有报错,但您期望的类型不在输出文件中。
按以下顺序检查三点:
-
模式: 如果该类型不在 Codegen 加载的模式中,就无法生成。请打开模式文件或自行运行自省查询确认它存在
-
文档: 只有 Codegen 找到的操作才会生成类型,如果包含查询的文件不被
documents的 glob 匹配到,就会缺少相应类型 -
插件: 自 v6 起,
typescript-operations仅生成您的操作所使用到的枚举和输入类型,因此如果您需要像Course这样的完整模式类型,请在单独输出文件中添加typescript插件
如果您使用了未安装的插件,Codegen 会给出清晰的报错:
Unable to find template plugin matching 'typescript-react-apollo'
Install one of the following packages:
- @graphql-codegen/typescript-react-apollo
Codegen 找不到操作
该错误意味着 documents 的 glob 没有匹配到任何包含操作的文件:
Unable to find any GraphQL type definitions for the following pointers:
- src/**/*.gql
检查 glob 的扩展名是否与您的文件一致。.gql 模式不会匹配 .graphql 文件,src/**/*.graphql 也不会匹配内联在 .ts 文件中的操作。
对内联操作,还要检查标签。Codegen 只会发现用 gql 包裹或带有 /* GraphQL */ 注释的模板字面量。仅包含查询的普通模板字符串对它是不可见的。
如果设置了 ignoreNoDocuments: true,Codegen 不会显示该错误,并会生成一个不包含操作类型的输出。在全新项目中这个选项有用,但后来可能会掩盖损坏的 glob。
生成的类型已过时
类型可编译,但未反映对操作或模式的最新更改。
Codegen 不会自行运行。每次修改 .graphql 文件、内联操作、模式或 codegen.ts 后都要重新运行。在开发期间,npm run codegen:watch 会替您完成。
如果在 CI 中类型仍然陈旧,请提交重新生成的文件,或让 prebuild 钩子在构建前重新生成。
生成的类型与 API 不匹配
类型可以编译,但服务器在运行时返回的数据形状不同。
这几乎总意味着 Codegen 与您的应用使用了不同的模式。例如,Codegen 读取的是一周前的本地 schema.graphql,而应用调用的生产 API 自那以后已经发生了改变。
检查 schema 键指向哪里。如果是文件,将它与应用调用的 API 进行比较并更新。如果是 URL,确保它与应用使用的是同一环境,而不是模式更新更快或更慢的预发服务器。
然后重新生成并查看差异。如果操作文件有变更而生成文件没有,操作很可能在 documents 的 glob 之外。
GraphQL Codegen 的最佳实践
Codegen 不需要大量配置也能良好运行。以下做法能在项目增长时保持这种状态:
-
保持模式与生成文件同步: 开发期间运行监听模式,构建前运行
prebuild,避免任何人使用旧模式对应的类型 -
生成面向操作的类型: 客户端代码使用
typescript-operations,因为它们与每个查询的获取内容精确匹配 -
仅在工作流需要时提交生成文件: 提交生成文件可在 PR 中审阅类型变化,并在 CI 中运行
--check捕捉过期输出;如果团队不需要,将输出文件夹加入.gitignore并在每次构建时生成 -
保持配置简短: 一个输出文件配两个插件就能覆盖大多数客户端项目,每多一个选项,就多一件下一位开发者在修改前必须理解的事情
-
在每次推送时在 CI 中运行 Codegen:
--check步骤或prebuild钩子可以捕捉有人在本地忘记重新生成的变更 -
切勿手动编辑生成文件: 下一次 Codegen 运行会覆盖您的改动,请改动模式、操作或配置本身
结论
GraphQL Codegen 接管了 GraphQL 开发中最重复的部分——把模式和操作手动转成 TypeScript 类型的工作。
无论项目多大,工作流都保持一致。您定义模式和操作,配置 Codegen,生成类型和带类型的文档节点,然后将它们导入到应用代码中。
最大价值在于,您的类型始终描述的是您实际调用的 API,而不是您不必再手写的代码。当模式发生变化时,编译器会告诉您哪些地方出了问题,从而及早发现错误。
若您想了解 GraphQL 在现代 NoSQL 数据库中的用法,请阅读我们的MongoDB 与 GraphQL 博文。
GraphQL Codegen 常见问题
GraphQL Codegen 有什么用?
GraphQL Codegen 会根据 GraphQL 模式和您的 GraphQL 操作生成代码。多数团队用它为查询和变更生成 TypeScript 类型与带类型的文档节点,从而无需手写和更新这些类型。它还可以为服务器生成解析器类型,并为特定客户端生成现成的 hooks。
使用 Codegen 是否需要运行中的 GraphQL 服务器?
不需要。Codegen 可以从本地 .graphql 文件读取模式,因此在没有任何服务器的情况下也能工作。只有当您把 schema 键指向 URL 时才需要运行中的服务器,因为 Codegen 会通过自省来获取模式。
是否应将生成的文件提交到 Git?
取决于您团队的工作流。提交生成文件可以在拉取请求中审阅类型变更,并在 CI 中使用 graphql-codegen --check 捕捉过期输出。如果不需要这些,将输出文件夹加入 .gitignore,在每次构建前重新生成即可。
我应该使用 client 预设还是单独的插件?
取决于您团队的工作流。The Guild 推荐大多数客户端应用使用 client 预设,因为它将操作类型、带类型文档节点和片段屏蔽一次性打包。Apollo 则推荐使用 typescript-operations 插件,因为该预设会添加运行时代码和与 Apollo Client 冲突的特性。如果您使用 Apollo,请选择插件;否则该预设是不错的默认选项。
更新 Codegen 后为什么会出现重复声明错误?
自 v6 起,typescript-operations 插件会生成自己的枚举和输入类型。如果您的配置仍在同一输出文件中同时列出 typescript 和 typescript-operations,两者都会声明诸如 Level 的类型,TypeScript 会抛出重复声明错误。请将 typescript 插件从该输出中移除;若仍需要完整模式类型,请将它移动到单独文件。