手写接口类型最危险的地方不是写起来累,而是它错了也能编译通过。这篇以博客后端的 Huma OpenAPI 和 scripts/gen-sdk.mjs 为例,拆解一条可落地的生成链路:类型怎么来、生成物长什么样、响应类型为什么会在统一信封处丢失,以及怎样把漂移检查放进 CI。

1. 手写类型的问题不是麻烦,是静默失效

前端最常见的写法是照着接口文档手抄一份类型:

interface Article {
  articleId: number;
  title: string;
  updated_at: string; // 后端字段其实叫 updatedAt
}

const article = (await res.json()) as Article;
console.log(article.updated_at); // 编译通过,运行期是 undefined

as Article 把 any 断言成了「我以为的形状」。字段改名、可空性变化、枚举新增值,这三类变更都不会让 TypeScript 报错,只会在某个页面上显示 undefined 或 Invalid Date。

要消除这种漂移,只能让类型从服务端的契约生成,而不是从人的记忆生成。这个项目的契约是 Huma 根据 Go 结构体产出的 OpenAPI 3.1,机器可读版本在 /api/api-doc.json。

2. 生成链路:从 Huma 到可提交的 SDK

整条链路只有四步:Go 结构体经 Huma 生成 OpenAPI 3.1,gen-sdk.mjs 拉取规范,写出 types.ts 与 client.ts,最后由 index.ts 统一导出。

生成器读取的地址可以用环境变量覆盖,默认指向本地服务:

const outDir = path.join(root, 'sdk/typescript/src');
const check = process.argv.includes('--check');
const source = process.env.OPENAPI_FILE || 'http://localhost:8000/api/api-doc.json';

--check 不是「重新生成再格式化」,而是逐字节比较:它把本次生成的内容和 sdk/typescript/src 里的现有文件对一遍,任何一个字节不同就打印 SDK 漂移:types.ts 并以退出码 1 结束。这意味着生成必须是确定性的:同样的 OpenAPI 输入要得到同样的字节。生成器没有时间戳、没有随机 ID,schema 的遍历顺序来自规范文件本身,所以这个前提成立。

Makefile 只包了两个薄目标:

sdk-gen:
	node scripts/gen-sdk.mjs

sdk-check:
	node scripts/gen-sdk.mjs --check

生成物提交进仓库,sdk/typescript/src/index.ts 统一导出 client 和 types,包名是 @limit-blog/sdk。

这里要诚实说明一个现状:管理后台目前还没有引用这个包。frontend/admin/src/lib/admin-api.ts 仍然是手写的 fetch 封装,lib/types.ts 里的响应类型也是手写的。SDK 生成链路已经能跑,但消费侧迁移没有完成,原因正是后面要讲的响应类型缺失。

3. 生成器只覆盖 OpenAPI 的一个子集

gen-sdk.mjs 的类型映射是一个显式的 switch,没有引入 json-schema-to-typescript 之类的重依赖:

function typeOf(schema, client = false) {
  if (!schema || typeof schema !== 'object') return 'unknown';
  if (schema.$ref) {
    const name = typeName(schema.$ref);
    return knownSchemas.has(name) ? (client ? `Types.${name}` : name) : 'unknown';
  }
  if (schema.enum) return schema.enum.map((v) => JSON.stringify(v)).join(' | ');
  switch (schema.type) {
    case 'string': return 'string';
    case 'integer':
    case 'number': return 'number';
    case 'boolean': return 'boolean';
    case 'array': return `${typeOf(schema.items, client)}[]`;
    default: return 'unknown';
  }
}

几个取舍值得说清楚:

  • unknown 而不是 any。遇到不认识的形状时,生成器选择让调用方显式收窄,而不是给一个能到处传递的 any。这条规则会在后面的 tagIds 上显出代价,但方向是对的。
  • $ref 先查 knownSchemas。生成 client.ts 时,只有 components.schemas 里真实存在的类型才加 Types. 前缀,避免引用一个未生成的 interface。oneOf / anyOf 会被展开成联合类型,这里同样省略。
  • 枚举直接变成字面量联合。contentType?: "markdown" | "html" 不是 string,赋值 'md' 会在编译期失败。
  • 对象内联。object 分支会把 properties 直接展开成字段,required 决定是否加问号。这里省略了那段实现,但它生成的正是 CreateArticleRequestBody 这类结构。

生成器不处理 minimum、maximum、format、nullable 这些约束。它们仍然在 OpenAPI 里,只是没有进入 TypeScript 类型。这是后面「边界」一节的主要来源。

4. 生成物长什么样,以及两个硬伤

请求体的生成结果基本可以直接用:

export interface CreateArticleRequestBody {
  content: string;
  contentType?: "markdown" | "html";
  slug?: string;
  tagIds?: unknown;
  title: string;
}

export interface UpdateArticleRequestBody {
  content?: string;
  contentType?: "markdown" | "html";
  expectedUpdatedAt?: string;
  tagIds?: number[];
  title?: string;
}

注意两组对比:

  • CreateArticleRequestBody.tagIds 是 unknown,同一个后端字段在 UpdateArticleRequestBody 里却是 number[]。根因在 Huma 的 OpenAPI 3.1 输出:[]uint 默认可空,schema 写成 "type": ["array", "null"];生成器的 switch (schema.type) 只处理字符串,数组形式的 type 直接落到 unknown。而 *[]uint 配合 omitempty 会被标成不可空,才是正常的 "type": "array"。这不是后端类型不一致,而是生成器没有覆盖 OpenAPI 3.1 的联合 type 写法。调用处必须自己收窄,不能闭眼传。
  • expectedUpdatedAt 是 string,不是 Date。这是对的:HTTP 上的时间就是字符串,转成 Date 是业务层的事。

客户端方法也是生成的:

async createArticle(params: {
  body: Types.CreateArticleRequestBody;
}): Promise<Types.ResponseBody> {
  const query = undefined;
  return this.request<Types.ResponseBody>(
    "POST", `/api/admin/articles`, query, (params as any).body,
  );
}

硬伤在返回类型。request<T> 的运行时逻辑是先解包再返回:

const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.code !== 200) {
  throw new ApiError(
    response.status,
    payload?.code ?? response.status,
    payload?.message ?? `HTTP ${response.status}`,
    payload?.data,
  );
}
return payload.data as T;

也就是说 createArticle 实际解析出的是 ArticleDetailData,但类型声明是 ResponseBody(即 { code, message, data } 的形状)。这是双重错位:运行时已经解包,类型却还停在信封上。调用方写 const page: Types.ResponseBody = await sdk.pageArticles(...) 能编译,但 page.list 根本不存在。

根因不在生成器,而在 OpenAPI 本身:统一响应结构里的 data 是 any,落到规范里只能是一段空 schema,生成器读到空 schema 就只能回退成 unknown。具体是文章还是分页,在 OpenAPI 这一层已经丢了,生成器再聪明也变不出来。

第二个硬伤是响应侧几乎没有领域类型。types.ts 里绝大多数 interface 都是请求体;响应只留下一个 ResponseBody。这意味着「让接口变更编译期报错」目前只覆盖了入参,出参仍然要靠手写类型或断言。

这也解释了为什么 frontend/admin/src/lib/types.ts 至今还有几百行手写类型:不是团队不知道生成器,而是生成的响应类型不够用。迁移的先后顺序应该是先补齐响应 schema,再替换调用层,否则只是把 any 换了个地方。

5. 在生成物外面包一层,把断言收敛到一处

在生成器修好之前,实用的做法是加一层薄封装:请求类型继续用生成的,响应类型手写一次,并把类型断言限制在这一层。

import { ApiError, LimitBlogClient } from '@limit-blog/sdk';

export interface ArticleAdminPage {
  total: number;
  pageNum: number;
  pageSize: number;
  totalPage: number;
  list: Array<{
    articleId: number;
    title: string;
    status: string;
    updatedAt: string;
  }>;
}

export async function pageArticles(
  sdk: LimitBlogClient,
  pageNum: number,
  keyword = '',
): Promise<ArticleAdminPage> {
  const raw = await sdk.pageArticles({ pageNum, pageSize: 10, keyword });
  // 生成物的返回类型是信封,运行时 request 已解包 data
  return raw as unknown as ArticleAdminPage;
}

这行 as unknown as 很丑,但它把「生成器类型不对」这件事暴露在一个函数里,而不是散落在每个页面。配套的做法是给这层封装写类型测试:用一份真实的 API 响应做 fixture,断言 ArticleAdminPage 的字段和 JSON 对得上。等到生成器能产出具体响应类型,删掉断言即可。

这层封装还有一个副作用:它把 SDK 升级变成了可控的 diff。生成器换代、OpenAPI 字段变化,都会先撞在 ArticleAdminPage 上,而不是散落到每个页面组件里。类型测试不需要跑浏览器,tsc --noEmit 就是最便宜的那道门禁。

更彻底的修法在服务端:不要让所有接口都返回同一个 data any 的信封,而是给每个操作声明具体的响应结构,让 OpenAPI 的 data 带上真实 schema。代价是 Go 侧要多写一层类型,收益是生成物里响应类型也能自动出现。这是契约设计问题,不是代码生成问题。

业务错误码也需要手写一层。生成器只认识 HTTP 响应,不认识 pkg/api/codes.go 里的登记表:

export const isArticleNotFound = (error: unknown): boolean =>
  error instanceof ApiError && error.code === 4001;

export const isConflict = (error: unknown): boolean =>
  error instanceof ApiError && error.code === 409;

ApiError 同时带 status 和 code:status 给网络层和监控看,code 给业务分支看。4001 是文章不存在,4002 是 slug 冲突,409 是乐观锁冲突。这些值来自服务端的错误码登记表,TypeScript 侧没有自动同步机制,只能靠约定和测试。

6. 进 CI:sdk-check 要有稳定的 spec 来源

sdk-check 的逻辑决定了它对输入非常敏感:它不关心「语义是否等价」,只关心「字节是否一致」。所以 CI 里必须先有一个稳定的 OpenAPI 来源,否则每次生成都可能因为字段顺序或示例值变化而误报。

当前仓库的 scripts/ci-local.sh 还没有调用 sdk-check,Makefile 里的目标已经就绪。要接进去,最直接的方式是让 CI 先启动服务,让默认的 http://localhost:8000/api/api-doc.json 可访问,再跑 make sdk-check;如果不想在 CI 里跑服务,就把规范快照成文件,用 OPENAPI_FILE 指向它:

OPENAPI_FILE=<path-to-openapi.json> make sdk-check

快照方案的关键是「快照必须由同一个提交生成」。如果快照是上一次发版留下的,检查就变成了拿旧契约校验新代码,只会制造噪音。

另外,version-check 已经把 sdk/typescript/package.json 纳入版本一致性校验,SDK 的版本号不会和主版本漂移。这是另一条容易漏掉的约束:生成物也是发布物的一部分。

7. 边界:枚举、可空、日期与分页

生成类型是「结构正确」,不是「取值正确」。下面四类边界,生成器都不会替你把关:

枚举。contentType?: "markdown" | "html" 只能保证你传的是这两个值之一,不能保证后端仍然接受它们。后端删掉 html 分支时,旧 SDK 的联合类型不会自动收窄,除非重新生成。所以枚举变更必须和 SDK 重新生成绑在同一次提交里。

可空字段。Go 的 *time.Time 序列化出来可能是 null。生成器产出的领域类型基本只有请求体,publishedAt?: string 里的问号表示「可以不传」,不表示「可以传 null」;响应侧没有生成类型,后台手写的 ArticleListItem 反而老老实实写了 publishedAt: string | null。如果拿生成类型直接当响应类型用,article.publishedAt.slice(0, 10) 在编译期合法,运行期可能炸。要么在封装层用 zod 把 null 收窄成 string | null,要么约定后端不返回 null 而返回空字符串——后者会污染数据,不推荐。

日期字符串。updatedAt、publishedAt、expectedUpdatedAt 在类型里都是 string,没有品牌类型,也没有格式校验。new Date('2026-10-23') 和 new Date('2026-10-23T00:00:00+08:00') 在时区上不是一回事。跨层传递时最好保持 RFC3339 原样,只在展示层格式化;乐观锁的 expectedUpdatedAt 更是必须原样回传,不能重新序列化。

分页契约。生成的查询参数是 pageNum?: number、pageSize?: number,但后端约束是 pageNum >= 1、1 <= pageSize <= 100。这些 minimum / maximum 没有进入 TypeScript,传 0 或 1000 都能编译。分页封装里要显式 clamp,并且信任服务端返回的 totalPage,不要在前端用 Math.ceil(total / pageSize) 重算——两者的取整边界一旦不同,翻页器就会多出一页空白。

这些约束没有进入类型,不代表不存在。比较务实的做法是在封装层用 zod 做一次运行期校验,把 unknown 变成可信的领域对象;校验失败时抛出带字段路径的错误,比页面上突然出现一个 undefined 更容易排查。

还有两个小边界:

  • operationId 决定生成的方法名。Huma 里改一个 OperationID,SDK 里就多一次改名,所有调用方编译失败。把它当成公开 API 对待。
  • 生成的客户端只支持 setToken,没有刷新逻辑。管理后台的 401 单飞刷新仍然在 lib/api.ts 里,不要让 SDK 再实现一套。

8. 小结

从 OpenAPI 生成 SDK 的价值,不是省掉手写 interface 的时间,而是把「后端字段变了」从运行期事故变成编译期错误。这条链路真正难的地方有三处:生成器只覆盖 schema 的一个子集、统一信封把响应类型擦成了 unknown、CI 需要一个稳定的 spec 来源。

把请求类型交给生成器,把响应类型和业务错误码收敛到一层薄封装,再用 sdk-check 锁住字节级漂移,这套组合已经足够让大部分接口变更在合并前暴露。等哪天服务端愿意为每个操作声明具体的响应 schema,生成物才能真正覆盖整条链路。

到这里,React 方向的三篇就写完了:请求边界、编辑器状态、接口契约。下一篇回到 Go,聊 GORM 在 SQLite、MySQL、PostgreSQL 之间的方言差异。