本节目标:理解「一份中立 schema」为什么是多语言团队的必然选择;掌握从 TypeScript 服务端实现导出 OpenAPI 文档的两种方式;学会用 openapi-typescript 与 orval 生成类型安全的客户端;了解 GraphQL 用 codegen 生成 TypedDocumentNode 的做法;并能按消费者类型在 tRPC、OpenAPI、GraphQL 之间做出判断。
16.2 OpenAPI / GraphQL Codegen
上一节我们把 tRPC 走通了,代价是消费者必须也是 TypeScript。但现实中总有例外:iOS 客户端要用 Swift 调你的接口、合作方要用 Go 写集成、甲方要看一份能导入 Postman 的文档。这时候「共享类型」这条路走不通了,需要换成共享 schema。
本节的主线是一条流水线:服务端实现 → 中立 schema → 各语言客户端。中间那份 schema 是唯一事实来源,两侧都由工具生成,人只负责写实现。
16.2.1 为什么需要一份中立 schema
tRPC 的做法是让客户端直接消费服务端的类型,这在同语言下是最短路径。但类型是编译器内部的数据结构,它无法跨语言传递,也无法被人阅读和评审。把类型「序列化」成一份文本协议,就得到了 schema。
以用户对象为例,同一个契约在三种表达里长这样:
| 形态 | 表达 | 消费者 |
|---|---|---|
| TS 类型 | type User = { id: string; userName: string } | 仅 TypeScript |
| OpenAPI | components.schemas.User + JSON Schema | 任意语言、工具链、文档站 |
| GraphQL SDL | type User { id: ID! userName: String! } | 任意语言、强类型查询 |
关键认知:schema 是契约的载体,类型只是它的一种投影。有了 schema,你可以同时得到文档(Swagger UI)、客户端 SDK(openapi-typescript)、服务端桩代码(openapi-generator)、以及契约测试的基准(schema diff)。
16.2.2 从实现导出 OpenAPI:Fastify 路线
最省事的做法是让框架从既有路由定义里自动产出文档。Fastify 用 @fastify/swagger,配合 @fastify/swagger-ui 提供交互式页面。
pnpm add @fastify/swagger @fastify/swagger-ui
// server/openapi.ts
import Fastify from 'fastify'
import swagger from '@fastify/swagger'
import swaggerUi from '@fastify/swagger-ui'
import { jsonSchemaTransform } from 'fastify-type-provider-zod'
const app = Fastify()
await app.register(swagger, {
openapi: {
openapi: '3.1.0',
info: { title: 'Acme API', version: '1.0.0' },
servers: [{ url: 'https://api.acme.dev' }],
},
transform: jsonSchemaTransform, // 把 Zod schema 转成 JSON Schema
})
await app.register(swaggerUi, { routePrefix: '/docs' })
jsonSchemaTransform 是这里的枢纽:它把 5.1 HTTP 服务与路由(Fastify / Hono)
里用 Zod 声明的 schema 转成 OpenAPI 认识的 JSON Schema。因此校验与文档来自同一份声明,不会出现「文档写了 maxLength: 32、代码里却是 64」这种偏差。
若不想引入 UI 依赖,也可以只导出静态文件交给 CI:
// scripts/export-openapi.ts
import { app } from '../server/app'
import { writeFileSync } from 'node:fs'
await app.ready()
writeFileSync('openapi.json', JSON.stringify(app.swagger(), null, 2))
pnpm tsx scripts/export-openapi.ts && npx openapi-typescript openapi.json -o src/api/schema.d.ts
一行命令,openapi.json 就变成了一个 .d.ts。这个文件不手改、进版本库、由 CI 校验是否过期——它是契约流水线的产物。
16.2.3 openapi-typescript:只要类型,不要运行时代码
openapi-typescript 的定位很克制:它只生成类型声明,不生成任何运行时代码。生成结果形如:
// src/api/schema.d.ts(自动生成,勿手改)
export interface paths {
'/users/{id}': {
get: operations['getUser']
delete: operations['deleteUser']
}
}
export interface operations {
getUser: {
parameters: {
path: { id: string }
query?: { fields?: string[] }
}
responses: {
200: { content: { 'application/json': components['schemas']['User'] } }
404: { content: { 'application/json': components['schemas']['Problem'] } }
}
}
}
有了这份声明,你可以用一个极薄的手写封装把 fetch 变成类型安全的调用:
// src/api/client.ts
import createClient from 'openapi-fetch'
import type { paths } from './schema'
export const api = createClient<paths>({ baseUrl: 'https://api.acme.dev' })
const { data, error, response } = await api.GET('/users/{id}', {
params: { path: { id: 'u_1' } },
})
if (data) {
console.log(data.userName) // 由 schema 推导
}
openapi-fetch 的返回是 { data, error, response } 判别结构而不是抛异常,这与 3.1 Result/Either 与类型化错误
的思路一致:错误是返回值的一部分,编译器会强制你处理 error 分支。
注意 error 的类型取决于 schema 里声明了哪些错误码。如果服务端只写了 200,那 error 就是 never——这不是工具的问题,而是文档没写全。契约的价值上限,等于你声明了多少。
16.2.4 orval:连 hook 一起生成
如果项目用 React Query,orval 更进一步:它不仅生成类型,还生成 useQuery / useMutation hook、mock 数据与 MSW handler。
// orval.config.ts
import { defineConfig } from 'orval'
export default defineConfig({
acme: {
input: { target: './openapi.json' },
output: {
target: './src/api/endpoints.ts',
client: 'react-query',
mock: true,
override: {
mutator: { path: './src/api/fetcher.ts', name: 'customFetch' },
},
},
},
})
pnpm orval
生成出来的调用点是这样:
const { data, isLoading } = useGetUser('u_1')
// data 的类型:User | undefined
收益是零手写调用层;代价是生成物体积大、升级 orval 会带来大量 diff。因此务必把生成目录排除在 code review 之外(在 .gitattributes 里标 linguist-generated,或干脆不提交、改为构建时生成)。若你更愿意自己掌控缓存键,回到 14.1 TanStack Query 类型推导
的手写方案会更合适。
16.2.5 GraphQL:schema 天生就是契约
GraphQL 与 OpenAPI 的差别在于契约的位置。OpenAPI 通常是「从实现反推文档」,而 GraphQL 是「先写 schema,实现去满足它」——schema 是一等公民,服务端必须实现它声明的所有字段,否则启动就失败。
用 @graphql-codegen/cli 生成客户端类型。下面这段 YAML 等价于项目根目录的 codegen.ts:
schema: https://api.acme.dev/graphql
documents: src/**/*.graphql
generates:
src/gql/:
preset: client
plugins: []
pnpm graphql-codegen --config codegen.ts
client preset 生成的是 TypedDocumentNode:一个既能在运行期当查询文档发送、又在编译期携带变量与结果类型的对象。
import { graphql } from './gql'
import { useQuery } from '@apollo/client'
const GetUser = graphql(`
query GetUser($id: ID!) {
user(id: $id) {
id
userName
}
}
`)
const { data } = useQuery(GetUser, { variables: { id: 'u_1' } })
// data?.user.userName 的类型由 query 文本推导,多写字段会报错
GraphQL 的一个隐性优势是客户端只请求它需要的字段,因此服务端新增字段永远不构成破坏性变更——这正是下一节「向后兼容」的核心原则。代价是服务端要处理查询深度、N+1 与复杂度限流。延伸阅读可参考 GraphQL Schema 版本演进 与 GraphQL 契约测试 。
16.2.6 三条路线对照
到这一节为止,你已经见过三种把契约固化的方式,它们的差异可以收敛成一张表:
| 维度 | tRPC | OpenAPI codegen | GraphQL codegen |
|---|---|---|---|
| 契约载体 | 服务端实现(TS 类型) | openapi.json | SDL schema |
| 是否需写声明 | 否 | 是(可由实现导出) | 是(先写 schema) |
| 跨语言消费者 | 不支持 | 支持 | 支持 |
| 文档 | 需额外工具 | 天然可出 Swagger UI | 天然可出 Playground |
| 传输方式 | 默认全 POST | REST 语义,可用 HTTP 缓存 | 单端点 POST |
| 生成物 | 无 | 类型 / SDK / hook | TypedDocumentNode |
| 主要代价 | 绑死 TS 全栈 | 文档可能落后于实现 | 服务端复杂度治理 |
选型判据只有一句话:消费者是不是自家 TS 前端。是,就用 tRPC 换取零声明;不是,就必须有一份可被外部消费的 schema,再按是否需要查询裁剪能力在 OpenAPI 与 GraphQL 之间选。
16.2.7 CI 中的契约门禁
代码生成最容易失控的地方不是生成本身,而是生成物与实现不同步。三种同步失败在线上表现各不相同:
| 失败形态 | 症状 | 防线 |
|---|---|---|
| 实现改了、文档没重生成 | 客户端类型是旧的,调用新字段报错 | CI 重生成后 git diff --exit-code |
| 文档改了、客户端没重生成 | 前端仍用旧类型,运行期字段缺失 | 同上,或 pre-commit 钩子 |
| 文档与实现本来就矛盾 | 线上 400/500,类型全对 | 契约测试(见下节) |
第一条防线的实现很直接,放进 CI 即可:
#!/usr/bin/env bash
set -euo pipefail
pnpm tsx scripts/export-openapi.ts
npx openapi-typescript openapi.json -o src/api/schema.d.ts
if ! git diff --quiet; then
echo "契约产物已过期,请在本地执行 pnpm gen:api 并提交"
git diff --stat
exit 1
fi
这段脚本把「忘了重新生成」变成了构建失败而不是线上事故。它与 1.3 代码规范与提交门禁(ESLint / Biome / husky) 属于同一类工程实践:把约定交给机器执行,而不是交给记忆。
16.2.8 三个高频坑
坑一:把 schema.d.ts 当成手写文件去改。 生成物一旦被手改,下一次生成就会覆盖,而且 diff 会变得难以阅读。正确做法是在文件头加 /* eslint-disable */ 与「DO NOT EDIT」注释,并在 .gitattributes 里标记为生成物。
坑二:anyOf / oneOf 生成出难以使用的联合类型。 JSON Schema 的 oneOf 在 TypeScript 侧会生成联合,若成员之间没有判别字段(discriminant),调用方就必须自己写类型守卫。治本的方法是在 schema 里给每个分支加一个 type 字面量字段,让生成的联合变成可判别的:
{
"oneOf": [
{ "type": "object", "required": ["kind", "url"],
"properties": { "kind": { "const": "image" }, "url": { "type": "string" } } },
{ "type": "object", "required": ["kind", "text"],
"properties": { "kind": { "const": "text" }, "text": { "type": "string" } } }
]
}
这与 10.1 WebSocket 消息协议判别联合
里给消息加 type 字段是同一个技巧:判别字段是让联合类型可用的前提。
坑三:版本号写死在代码里、schema 里却忘了改。 info.version 是客户端生成时唯一能读到的版本信息,它应该由 package.json 或 Git tag 注入,而不是手写。常见错误是发版后 schema 里仍是 1.0.0,导致客户端无法判断兼容性。
小结
本节的核心结论是:当消费者不再是 TypeScript 时,类型必须降级为一份可被所有人读懂的 schema。
- schema 是契约的载体,TypeScript 类型只是它的一种投影;有 schema 才有文档、SDK、桩代码与契约测试的基准;
- Fastify +
@fastify/swagger+jsonSchemaTransform能从既有 Zod 声明导出 OpenAPI,校验与文档同源,不会漂移; openapi-typescript只生成类型,配合openapi-fetch得到{ data, error }判别式调用;orval连 hook 与 mock 一起生成,代价是生成物体积与升级 diff;- GraphQL 的 schema 是强制契约,
clientpreset 产出 TypedDocumentNode,查询文本即类型来源; - 三条路线的判据是「消费者是不是自家 TS 前端」,是则 tRPC,否则按是否需要字段裁剪在 OpenAPI 与 GraphQL 之间选;
- CI 里用「重生成 +
git diff --exit-code」把同步问题变成构建失败,是最划算的一道防线; - 三个高频坑:手改生成物、
oneOf缺少判别字段、info.version忘记注入。
契约本身会随业务演进,字段会加、语义会改、旧版本要下线。下一节我们讨论最容易被忽略的一环:改了契约以后,怎么做到不打断正在运行的旧客户端。
阅读导航:上一节:16.1 tRPC 端到端类型安全 · 下一节:16.3 契约版本演进与兼容 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。