《TypeScript编程实战》16.2 OpenAPI / GraphQL Codegen

本节讲清「schema 先行」这条契约路线的完整闭环:如何从 TypeScript 服务端实现自动导出 OpenAPI 文档,如何用 openapi-typescript 与 orval 把文档变成带类型的客户端 SDK,以及 GraphQL 如何借助 codegen 生成 TypedDocumentNode,最后给出三条路线的选型判据与 CI 门禁。

本节目标:理解「一份中立 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
OpenAPIcomponents.schemas.User + JSON Schema任意语言、工具链、文档站
GraphQL SDLtype 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 三条路线对照

到这一节为止,你已经见过三种把契约固化的方式,它们的差异可以收敛成一张表:

维度tRPCOpenAPI codegenGraphQL codegen
契约载体服务端实现(TS 类型)openapi.jsonSDL schema
是否需写声明否是(可由实现导出)是(先写 schema)
跨语言消费者不支持支持支持
文档需额外工具天然可出 Swagger UI天然可出 Playground
传输方式默认全 POSTREST 语义,可用 HTTP 缓存单端点 POST
生成物无类型 / SDK / hookTypedDocumentNode
主要代价绑死 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 是强制契约,client preset 产出 TypedDocumentNode,查询文本即类型来源;
  • 三条路线的判据是「消费者是不是自家 TS 前端」,是则 tRPC,否则按是否需要字段裁剪在 OpenAPI 与 GraphQL 之间选;
  • CI 里用「重生成 + git diff --exit-code」把同步问题变成构建失败,是最划算的一道防线;
  • 三个高频坑:手改生成物、oneOf 缺少判别字段、info.version 忘记注入。

契约本身会随业务演进,字段会加、语义会改、旧版本要下线。下一节我们讨论最容易被忽略的一环:改了契约以后,怎么做到不打断正在运行的旧客户端。

阅读导航:上一节:16.1 tRPC 端到端类型安全 · 下一节:16.3 契约版本演进与兼容 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes