GraphQL 代码生成与端到端类型安全:graphql-codegen 实战

用 graphql-codegen 打通从 Schema 到客户端的端到端类型安全:Typed Document Node、fragment colocation 与复用、多端(Web / RN / Node)生成策略、与 TypeScript 类型系统的衔接、CI 漂移校验与常见陷阱。

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 类型typescriptUser、Role 等基础类型
操作类型typescript-operationsGetUserQuery、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 多端差异的处理

差异点WebReact NativeNode
标量映射Date → DateDate → stringDate → Date
类型名冲突无需前缀需前缀
Fragment 复用共享包共享包服务端独有
生成目录src/generatedsrc/generatedsrc/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 类型建议映射
IDstringstring
DateTimeanystring 或 Date(需一致)
JSONanyunknown(强制收窄)
BigIntanystring 或 bigint
自定义 Moneyany{ 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 Clienttyped-document-node + typescript-operations
Relayrelay-compiler(内置类型生成)
urqltyped-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 类型系统进阶

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「GraphQL」更多文章

  1. GraphQL 事件驱动集成:订阅、Webhook 与消息队列
  2. REST 到 GraphQL 的渐进迁移:绞杀者模式与双栈并存
  3. GraphQL 数据库与 ORM 集成:DataLoader、事务与查询下推