GraphQL 最大的卖点之一是"类型系统",但如果查询字符串在运行时才校验,客户端拿到的其实是 any。真正的端到端类型安全,要求 Schema 的类型信息一路流到客户端代码里——查询写错字段时 IDE 就报红,响应字段拼错时 tsc 就失败。graphql-codegen 正是这条链路的枢纽。本文从类型断层讲起,逐步展开 Typed Document Node、fragment 复用、多端生成策略、TS 衔接与 CI 校验。若想先补齐类型系统基础,可阅读 https://plumephp.com/graphql-fundamentals/;服务端类型生成则可参考 https://plumephp.com/graphql-server-implementation/。
一、类型断层:Schema 与客户端之间的鸿沟
1.1 问题的本质
服务端有强类型 Schema,客户端有 TypeScript,但两者之间隔着一层字符串。手写类型意味着维护两份真相,而两份真相必然漂移。
// ❌ 手写类型:与 Schema 无关联,改字段不会报错
interface User {
id: string;
name: string; // Schema 里可能已经改成了 fullName
avatarUrl?: string;
}
// 查询返回的其实是 { id, fullName, avatar { url } }
const { data } = useQuery(gql`
query GetUser { user { id fullName avatar { url } } }
`);
console.log(data.user.name); // undefined,运行时才炸
1.2 类型安全的三个层次
| 层次 | 校验时机 | 覆盖内容 |
|---|---|---|
| 查询合法性 | 构建时 | 字段是否存在、参数是否匹配 |
| 变量类型 | 构建时 | 变量声明与 TS 类型一致 |
| 响应类型 | 构建时 | 返回值结构与字段类型 |
只有三层全绿,才算端到端类型安全。
1.3 codegen 的定位
# 一次生成,处处受用
npm i -D @graphql-codegen/cli \
@graphql-codegen/typescript \
@graphql-codegen/typescript-operations \
@graphql-codegen/typed-document-node
一句话总结:类型断层的根因是"Schema 是类型的唯一真相,而客户端却在手抄它"——codegen 让抄写变成生成。
二、graphql-codegen 核心机制
2.1 配置结构
# codegen.ts 或 codegen.yml
schema: './schema.graphql' # 或 http://localhost:4000/graphql
documents: 'src/**/*.{graphql,tsx,ts}'
generates:
src/generated/graphql.ts:
plugins:
- typescript # 生成 Schema 基础类型
- typescript-operations # 生成查询/变更的类型
- typed-document-node # 生成类型化 DocumentNode
config:
scalars:
DateTime: string
JSON: unknown
avoidOptionals: true
dedupeFragments: true
2.2 三层生成产物
| 产物 | 来源插件 | 用途 |
|---|---|---|
| Schema 类型 | typescript | User、Role 等基础类型 |
| 操作类型 | typescript-operations | GetUserQuery、GetUserQueryVariables |
| 类型化文档 | typed-document-node | 带类型的 DocumentNode |
2.3 运行生成
# 一次性生成
npx graphql-codegen --config codegen.ts
# 监听模式(开发时)
npx graphql-codegen --config codegen.ts --watch
# 在 package.json 中固定脚本
# "codegen": "graphql-codegen --config codegen.ts"
一句话总结:codegen 的产物不是"辅助类型",而是客户端与 Schema 之间的唯一合法接口——业务代码只应消费生成类型,绝不手写。
三、Typed Document Node 与操作类型
3.1 从字符串到类型化文档
typed-document-node 是端到端类型安全的关键插件,它把查询字符串编译成携带泛型信息的 DocumentNode。
// 输入:src/queries/GetUser.graphql
// query GetUser($id: ID!) {
// user(id: $id) { id fullName avatar { url } }
// }
// 输出:src/generated/graphql.ts
import { TypedDocumentNode } from '@graphql-typed-document-node/core';
export const GetUserDocument = {
kind: 'Document',
// ...
} as unknown as TypedDocumentNode<GetUserQuery, GetUserQueryVariables>;
export type GetUserQuery = {
user: {
id: string;
fullName: string;
avatar: { url: string } | null;
} | null;
};
export type GetUserQueryVariables = { id: string };
3.2 消费端的类型推断
import { useQuery } from '@apollo/client';
import { GetUserDocument } from '../generated/graphql';
function Profile({ id }: { id: string }) {
// data 的类型自动推断为 GetUserQuery | undefined
const { data, loading } = useQuery(GetUserDocument, {
variables: { id }, // ✅ 缺少或类型错误会立即报错
});
if (loading) return <Spinner />;
// data.user.fullName 全程有类型提示
return <h1>{data?.user?.fullName}</h1>;
}
3.3 变量与响应的双向约束
// 变量错误:id 应为 string,传 number 报错
useQuery(GetUserDocument, { variables: { id: 123 } });
// ~~~~~~~~ Type 'number' is not assignable to 'string'
// 响应字段错误:Schema 中没有 email
const email = data?.user?.email;
// ~~~~~ Property 'email' does not exist
一句话总结:Typed Document Node 把"运行时才发现的字段拼写错误"提前到了"保存文件的那一刻"。
四、Fragment 复用与 Colocation
4.1 Fragment 是类型复用的载体
# src/components/UserCard.fragment.graphql
fragment UserCard on User {
id
fullName
avatar { url }
}
// codegen 生成 UserCardFragment 类型
import { UserCardFragment } from '../generated/graphql';
export function UserCard({ user }: { user: UserCardFragment }) {
// user 只包含 fragment 声明的字段,组件依赖被显式化
return (
<div>
<img src={user.avatar?.url} alt={user.fullName} />
<span>{user.fullName}</span>
</div>
);
}
4.2 Colocation:组件与数据需求同行
Colocation 的核心主张是:组件声明自己需要的数据,父组件负责组装。
# 父查询只关心"谁",不关心"长什么样"
query UserList {
users {
id
...UserCard
}
}
| 模式 | 优点 | 缺点 |
|---|---|---|
| 集中式查询 | 一处可见全部字段 | 组件依赖隐式、易过度获取 |
| Colocation | 依赖显式、易重构 | 需要 fragment 组合工具 |
| 混合 | 平衡 | 需要团队约定 |
4.3 Fragment 的自动展开
在 codegen 配置中开启 nonOptionalTypename: true(帮助 Apollo 缓存归一化)与 dedupeFragments: true(避免重复定义),即可让 fragment 类型在多处引用时正确合并,无需手工干预。
一句话总结:Fragment 不只是"查询片段",它是组件与 Schema 之间的契约——用好了,重构时编译器会替你检查每一处依赖。
五、多端生成:Web / React Native / Node
5.1 一次 Schema、多份产物
# codegen.ts —— 单配置多输出
generates:
# Web:Apollo Client
apps/web/src/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
- typed-document-node
documents: 'apps/web/src/**/*.graphql'
# React Native:同插件、不同 scalars
apps/mobile/src/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
- typed-document-node
documents: 'apps/mobile/src/**/*.graphql'
config:
scalars:
DateTime: string # RN 不用 Date 对象
# Node 服务端:生成 resolver 类型
apps/api/src/generated/resolvers.ts:
plugins:
- typescript
- typescript-resolvers
config:
contextType: '../context#Context'
mappers:
User: '../models#UserModel'
5.2 多端差异的处理
| 差异点 | Web | React Native | Node |
|---|---|---|---|
| 标量映射 | Date → Date | Date → string | Date → Date |
| 类型名冲突 | 无 | 需前缀 | 需前缀 |
| Fragment 复用 | 共享包 | 共享包 | 服务端独有 |
| 生成目录 | src/generated | src/generated | src/generated |
5.3 共享 Fragment 包
把 fragment 抽成独立的 npm 包(如 @acme/graphql-fragments),通过 exports 暴露 *.graphql 与生成产物。让 Web 与 RN 共享同一份 fragment 定义,codegen 在各端分别展开,既避免重复维护,又保持各端类型独立。
一句话总结:多端生成的正确姿势是"共享 fragment、独立生成"——共享的是数据需求,独立的是各端类型细节。
六、与 TypeScript 类型系统的衔接
6.1 生成类型 vs 手写类型的边界
// ✅ 生成类型:网络层数据结构
import type { GetUserQuery } from '../generated/graphql';
// ✅ 手写类型:领域模型(可能与网络结构不同)
interface UserProfile {
displayName: string;
joinedAt: Date;
}
// 显式映射:把网络类型转换为领域类型
function toProfile(raw: GetUserQuery['user']): UserProfile | null {
if (!raw) return null;
return {
displayName: raw.fullName,
joinedAt: new Date(raw.createdAt), // 标量 string → Date
};
}
6.2 标量映射的陷阱
| GraphQL 标量 | 默认 TS 类型 | 建议映射 |
|---|---|---|
ID | string | string |
DateTime | any | string 或 Date(需一致) |
JSON | any | unknown(强制收窄) |
BigInt | any | string 或 bigint |
自定义 Money | any | { amount: number; currency: string } |
// 用 unknown 强制显式收窄,避免 any 渗透
const meta = data.node.metadata as unknown;
if (isOrderMeta(meta)) {
console.log(meta.trackingNo);
}
6.3 判别联合与 __typename
// 联合类型查询生成判别联合
type SearchResult =
| { __typename: 'User'; id: string; fullName: string }
| { __typename: 'Order'; id: string; total: number };
// switch 收窄,exhaustive check 保证不漏分支
function render(r: SearchResult) {
switch (r.__typename) {
case 'User': return r.fullName;
case 'Order': return `¥${r.total}`;
default: {
const _exhaustive: never = r;
return _exhaustive;
}
}
}
一句话总结:生成类型负责"网络契约",手写类型负责"领域语义",两者之间应当有一层显式映射,而不是互相污染。
七、CI 校验与漂移检测
7.1 漂移是怎么产生的
开发者改了 .graphql 文件却忘了跑 codegen,提交的生成文件与查询不一致——这就是漂移。漂移会在 CI 或运行时才暴露。
7.2 在 CI 中拦截漂移
# .github/workflows/codegen-check.yml
name: Codegen Check
on: [pull_request]
jobs:
codegen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- name: Regenerate types
run: npm run codegen
- name: Fail if generated files drifted
run: |
if ! git diff --quiet; then
echo "::error::Generated types are out of date. Run 'npm run codegen'."
git diff --stat
exit 1
fi
7.3 校验项清单
| 校验项 | 命令 | 目的 |
|---|---|---|
| 生成一致 | git diff --exit-code | 防止漂移 |
| 查询合法 | graphql-inspector validate | 查询与 Schema 匹配 |
| 类型编译 | tsc --noEmit | 生成类型可用 |
| Schema 兼容 | graphql-inspector diff | 无破坏性变更 |
7.4 本地钩子
# lefthook / husky 中在提交前自动生成
npx lefthook add pre-commit
# lefthook.yml
# pre-commit:
# commands:
# codegen:
# glob: "**/*.graphql"
# run: npm run codegen && git add src/generated
一句话总结:漂移检测的唯一可靠手段是"在 CI 里重新生成一遍,然后比对 git diff"——本地靠自觉永远不可靠。
八、实践陷阱与最佳组合
8.1 常见陷阱
| 陷阱 | 症状 | 解法 |
|---|---|---|
| 生成文件被手改 | 下次生成被覆盖 | 加文件头注释 + lint 排除 |
any 标量渗透 | 类型安全失效 | 显式 scalars 映射 |
| Fragment 重复定义 | 编译错误 | dedupeFragments: true |
| 过度获取 | 传输膨胀 | 用 fragment 精确声明 |
| 生成目录入库 | diff 噪音 | 入库但用 .gitattributes 标记 |
8.2 生成文件的标记
# .gitattributes —— 让生成文件在 diff 中折叠
src/generated/* linguist-generated=true
/* eslint-disable */
// @generated by graphql-codegen — DO NOT EDIT
// 手动修改将在下次生成时丢失
8.3 推荐工具组合
在 package.json 中固定 codegen、codegen:watch、schema:print、typecheck 四个脚本,让生成与校验成为团队共识的入口。
| 场景 | 推荐插件 |
|---|---|
| Apollo Client | typed-document-node + typescript-operations |
| Relay | relay-compiler(内置类型生成) |
| urql | typed-document-node + typescript-operations |
| Node 服务端 | typescript-resolvers + mappers |
| GraphQL 请求校验 | graphql-inspector validate |
端到端类型安全不是"上一个插件"就完成的,它需要生成、消费、校验三个环节闭合。当 tsc 能在提交前拦住每一次字段拼写错误,GraphQL 的类型系统才算真正为你所用。要理解类型系统本身的设计,还需回到接口、联合类型与输入类型的建模原则;若涉及客户端缓存与类型归一化,则要把 fragment 与 __typename 一并纳入设计。
代码生成把 GraphQL 从"运行时契约"升级为"编译时契约"。它消灭了手写类型的漂移,把重构的风险交给编译器,让 Schema 成为整个前端与服务端共享的单一真相。投入一次配置,收获的是长期的安全感。
一句话总结
graphql-codegen 的终极价值是:让 Schema 成为唯一真相,让类型错误在保存文件时就暴露,而不是在用户点击时爆发。
FAQ
Q1: 生成文件应该提交到 Git 吗?
A: 两种做法各有拥趸。提交的好处是 CI 无需生成、IDE 开箱可用、漂移可被 git diff 检出;不提交的好处是仓库干净。推荐提交,并在 CI 中用 git diff --exit-code 做漂移校验,同时用 .gitattributes 减少 diff 噪音。
Q2: typed-document-node 和 typescript-operations 必须一起用吗?
A: 建议一起用。typescript-operations 生成操作的响应/变量类型,typed-document-node 生成携带泛型的 DocumentNode。只用前者会退化为"手动把类型传给 hook",失去自动推断。
Q3: 标量映射成 any 有什么风险?
A: any 会污染整个类型链——从 any 派生的字段全部失去检查。建议一律映射为 unknown 或具体类型(如 DateTime → string),强制开发者显式收窄。
Q4: 大项目生成很慢怎么办?
A: 分片生成:按 app 或按领域拆成多个 generates 条目,只生成改动部分的类型。配合 --watch 在开发时增量生成,CI 中全量生成。
Q5: Fragment colocation 会导致查询碎片化、难调试吗?
A: 会有一定代价。缓解方式:用 Apollo Client DevTools 查看完整查询、在 codegen 中开启 dedupeFragments、为每个 fragment 写明用途注释。收益(依赖显式、重构安全)通常大于成本。
相关阅读
- https://plumephp.com/graphql-schema-design-advanced/ —— 接口、联合类型与输入类型设计
- TypeScript 专题 —— TS 类型系统进阶
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。