背景:在团队协作开发,前后端开发接口对齐永远是一道难题。在TS的世界里要保证类型安全往往浪费不必要的时间在定义类型上了。
实践方案:遵循 design-first,先设计接口,确定openapi文档。然后生成服务端API模板和前端请求SDK,保证类型安全,同时节省繁琐重复的代码编写时间。
约定 openAPI文档(通过apifox等方式)
这里展示使用apifox导出 openAPI描述文件。
一份简单的openAPI文档的json格式描述如下(默认模版.openapi.json):
{
"openapi": "3.0.1",
"info": {
"title": "默认模块",
"description": "",
"version": "1.0.0"
},
"tags": [],
"paths": {
"/sessions": {
"post": {
"summary": "登录",
"deprecated": false,
"description": "",
"tags": [],
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": [
"email",
"password"
]
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {}
}
}
}
}
},
"security": []
}
},
},
"components": {
"schemas": {},
"responses": {},
"securitySchemes": {}
},
"servers": [],
"security": []
}
paths下定义了你的路由(包括路径、方法、入参、响应等等)。
openAPI一般有两个导出形式json和yaml。这里简单起见,只放了1个登录接口(/sessions)的定义。
后端
后端框架,有很多选项,比如express/koa,hono,nest等等。我选择了hono,主要因为能支持bun/node多运行时和性能不错。
生成 hono 代码有两种比较推荐的方式:
下面主要介绍使用 hono-takibi 。如果是需要生成其他TS服务端框架的模板代码,可以选择使用Kubb 。如果是针对Java Springboot 则使用openapi-generator
hono-takibi生成模板
首先介绍下@hono/zod-openapi,这个是在hono框架的基础上,提供了http入参校验(基于zod)和文档生成(代码即文档)。
使用hono-takibi生成的代码是基于@hono/zod-openapi。基于json/yaml文件生成命令如下:
npx hono-takibi [path/openapi.json] -o [path/routes.ts]
比如我在项目根目录下,生成模板代码。

以登录注册为例,下面是生成的服务端代码routes.ts
import { createRoute, z } from '@hono/zod-openapi'
export const postSessionsRoute = createRoute({
method: 'post',
path: '/sessions',
tags: [],
summary: '登录',
request: {
body: {
content: {
'application/json': {
schema: z
.object({ email: z.string(), password: z.string() })
.openapi({ required: ['email', 'password'] }),
},
},
},
},
responses: { 200: { content: { 'application/json': { schema: z.object({}) } } } },
security: [],
})
export const postUsersRoute = createRoute({
method: 'post',
path: '/users',
tags: [],
summary: '注册',
request: {
body: {
content: {
'application/json': {
schema: z
.object({
email: z.string().openapi({ title: '邮箱' }),
password: z.string().openapi({ title: '密码' }),
emailCode: z.string().openapi({ title: '邮箱验证码' }),
inviteCode: z.string().exactOptional().openapi({ title: '邀请码' }),
})
.openapi({ required: ['email', 'password', 'emailCode'] }),
},
},
},
},
responses: { 200: { content: { 'application/json': { schema: z.object({}) } } } },
security: [],
})
存在这么几个问题:
- 对于请求体的内容
request.body.content 可以发现schema 是内联的,这不利于复用zod schema(不利于类型复用)。同理 还有request.query、responses等。
- responses这里的响应体结构,没有做到复用。你可以看到
{ 200: { content: {...}}}这种重复,这种不利于统一维护。
那么如何解决?
- 针对内联schema,我们这样解决:在apifox设计阶段,建立
数据模型(这个是对应到服务端的DTO对象),最好是符合命名规范,对于query/body中的入参命名为XxxDto,对于响应结果命名为XxxResponseDto。
- 针对响应体结构,定义统一的响应组件:在apifox设计阶段,建立
响应组件(针对不同状态码200/201/400/401等)。同时针对固定响应结构需要设计一个数据模型ApiResponseDto 来填充。
数据模型和一些特定状态码的响应结构:

登录接口示例:

最终 再使用 hono-takibi 生成一下服务端 代码,如下:
import { createRoute, z } from '@hono/zod-openapi'
const UserResponseDtoSchema = z
.object({ id: z.string(), email: z.string(), username: z.string(), avatar: z.string() })
.openapi({ required: ['id', 'email', 'username', 'avatar'] })
.openapi('UserResponseDto')
const CreateUserDtoSchema = z
.object({
email: z.string().openapi({ title: '邮箱' }),
password: z.string().openapi({ title: '密码' }),
emailCode: z.string().openapi({ title: '邮箱验证码' }),
inviteCode: z.string().exactOptional().openapi({ title: '邀请码' }),
})
.openapi({ required: ['email', 'password', 'emailCode'] })
.openapi('CreateUserDto')
const LoginDtoSchema = z
.object({ email: z.string(), password: z.string() })
.openapi({ required: ['email', 'password'] })
.openapi('LoginDto')
const ApiResponseDtoSchema = z
.object({
code: z.int().openapi({ description: '业务号码' }),
data: z.object({}).nullable().openapi({ description: '业务数据' }),
message: z.string().exactOptional().openapi({ description: '消息' }),
})
.openapi({ required: ['code', 'data'] })
.openapi('ApiResponseDto')
const LoginResponseDtoSchema = z
.object({
accessToken: z.string().openapi({ description: '身份token' }),
user: UserResponseDtoSchema,
})
.openapi({ required: ['accessToken', 'user'] })
.openapi('LoginResponseDto')
const SuccessNullResponse = {
description: '无内容的成功响应',
content: { 'application/json': { schema: ApiResponseDtoSchema } },
}
const UnprocessableResponse = {
description: '无法处理请求,失败响应',
content: { 'application/json': { schema: ApiResponseDtoSchema } },
}
export const postSessionsRoute = createRoute({
method: 'post',
path: '/sessions',
tags: [],
summary: '登录',
request: { body: { content: { 'application/json': { schema: LoginDtoSchema, examples: {} } } } },
responses: {
200: {
description: '成功',
headers: z.object({}),
content: {
'application/json': {
schema: z
.object({
code: z.int().openapi({ description: '业务号码' }),
data: LoginResponseDtoSchema.nullable().openapi({ description: '业务数据' }),
message: z.string().exactOptional().openapi({ description: '消息' }),
})
.openapi({ required: ['code', 'data'] }),
},
},
},
400: UnprocessableResponse,
},
security: [],
})
export const postUsersRoute = createRoute({
method: 'post',
path: '/users',
tags: [],
summary: '注册',
request: {
body: { content: { 'application/json': { schema: CreateUserDtoSchema, examples: {} } } },
},
responses: {
200: {
description: '成功',
headers: z.object({}),
content: {
'application/json': {
schema: z
.object({
code: z.int().openapi({ description: '业务号码' }),
data: z.object({}).nullable().openapi({ description: '业务数据' }),
message: z.string().exactOptional().openapi({ description: '消息' }),
})
.openapi({ required: ['code', 'data'] }),
},
},
},
400: UnprocessableResponse,
},
security: [],
})
P.S.有个小问题,就是你定义 响应组件/响应时,一定要定义描述(description),否则生成代码会出现TS问题。

定义了description的效果:

集成到hono提供API服务
需要注意:前面提到的routes.ts中生成的xxxRoute是对参数校验和文档描述。实际上的路由逻辑是下面这样的:
import { OpenAPIHono } from '@hono/zod-openapi'
import { cors } from 'hono/cors'
import { postSessionsRoute, postUsersRoute } from './routes'
const app = new OpenAPIHono()
app.use(
'/*',
cors({
origin: '*',
})
)
app.get('/', (c) => {
return c.text('Hello Hono!')
})
app.openapi(postSessionsRoute, async (c) => {
const { password } = c.req.valid('json')
if (password.length < 6) {
return c.json(
{
code: 400,
data: null,
message: '密码长度小于6',
},
400
)
}
// TODO: 实现登录逻辑
return c.json({
code: 0,
data: {
accessToken: 'token',
user: {
id: '1',
email: 'user@example.com',
username: 'user',
avatar: '',
},
},
})
})
app.openapi(postUsersRoute, async (c) => {
const { password } = c.req.valid('json')
if (password.length < 6) {
return c.json(
{
code: 400,
data: null,
message: '密码长度小于6',
},
400
)
}
// TODO: 实现注册逻辑
return c.json({
code: 0,
data: null,
})
})
export default app
使用 bun dev 运行(这是一个honojs项目),然后在apifox中测试如下:

前端
生成TS客户端代码,选择就很多了
这里没有推荐 openapi-generator 了,因为确实存在一些局限性,生成的前端SDK并不好用,在TS安全类型上不如其他选择(运行这个工具还要折腾Java环境)。
下面重点介绍下 openapi-typescript + openapi-fetch 和hey-api 2种方式。
openapi-typescript + openapi-fetch
使用
1.安装 两个依赖
pnpm add openapi-typescript -D
pnpm add openapi-fetch
2.运行openapi-typescript 生成ts类型
npx openapi-typescript "../默认模块.openapi.json" -o "app/utils/openapi/schema.d.ts"
3.编写客户端代码
import createClient from "openapi-fetch";
import type { paths } from "./schema";
export const client = createClient<paths>({ baseUrl: "http://127.0.0.1:3000" });
// Export type for use in components
export type Client = typeof client;
4.一个登录例子:
const {
data, // only present if 2XX response
error // only present if 4XX or 5XX response
} = await client.POST("/sessions", {
body: {
email,
password,
},
});
if (error) {
setStatus("error");
setMessage(error.message || "登录失败");
} else if (data) {
setStatus("success");
setMessage("登录成功!");
console.log("Login successful:", data);
// 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
// const token = data.data?.accessToken;
}
一个好的 fetch 包装器绝对不应该使用泛型。 泛型需要更多的输入,而且可能会隐藏错误!
可以看出client 提供了GET、POST等方法,熟悉的写法,传入url和body参数。返回的结果包括data和error,data就是我们前面定义的ApiResponseDto,如下:
const data: {
code: number;
data: {
accessToken: string;
user: {
id: string;
email: string;
username: string;
avatar: string;
};
};
message?: string | undefined;
} | undefined
而 error,就是当状态码不是2xx时,不空,类型是UnprocessableResponse | XxxErrorResponse类型,如下:
const error: {
code: number;
data: Record<string, never> | null;
message?: string;
} | undefined
特性
1.支持的请求库如下:
| Library |
Size (min) |
| openapi-fetch |
6 kB |
| openapi-typescript-fetch |
3 kB |
| feature-fetch |
15 kB |
| openapi-axios |
32 kB |
2.支持「中间件」。
使用axios的同学,肯定对请求拦截和响应拦截不陌生,而openapi-fetch 提供中间件完成同样的功能:
import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./schema";
const myMiddleware: Middleware = {
async onRequest({ request, options }) {
// set "Authorization" header,认证
request.headers.set("Authorization", "Bearer " + "your_access_token");
return request;
},
async onResponse({ request, response, options }) {
const { body, ...resOptions } = response;
console.log('body', body); // ReadableStream
console.log('response', response);
if (response.status === 401) {
const error = new Error("Unauthorized");
(error as Error & { status?: number }).status = 401;
window.location.href = "/login";
return
}
return response;
// 或者 return new Response(body, { ...resOptions});
},
async onError({ error }) {
// wrap errors thrown by fetch
console.log('error', error);
if (error instanceof Error) {
return error;
}
return new Error("Oops, fetch failed", { cause: error });
},
};
export const client = createClient<paths>({ baseUrl: "http://127.0.0.1:3000" });
// register middleware
client.use(myMiddleware);
// Export type for use in components
export type Client = typeof client;
需要注意,openapi-fetch 一般不会抛出错误,比如401/403之类错误状态码(除非你在onResponse中手动抛出错误)。onError 回调函数允许你处理 fetch 抛出的错误。常见的错误包括 TypeError (当出现网络或 CORS 错误时可能发生)和 DOMException (当使用 AbortController 中止请求时可能发生)。
3.支持使用 DTO类型
之前生成的schema.d.ts中定义了interface components:
export interface components {
schemas: {
UserResponseDto: {
id: string;
email: string;
username: string;
avatar: string;
};
CreateUserDto: {
/** 邮箱 */
email: string;
/** 密码 */
password: string;
/** 邮箱验证码 */
emailCode: string;
/** 邀请码 */
inviteCode?: string;
};
LoginDto: {
email: string;
password: string;
};
...
可以这样使用:
import { client , type components } from "~/utils/openapi";
const body: components["schemas"]["LoginDto"] = {
email,
password,
}
4.对框架的支持
通过openapi-react-query库,也能支持结合tanstack query使用。use-query
hey-api
基础使用和特性
1.hey-api生成代码时,会创建一个文件夹(默认是"client")存放内容,和openapi-typescript相比,是它生成了一个默认的client,并且每个API都提供了方法直接调用(无需路径)
2.安装
pnpm add @hey-api/openapi-ts -D
3.配置openapi-ts命令
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"openapi-ts": "openapi-ts --input ../默认模块.openapi.json --output ./app/utils/heyapi"
}
参数复杂了,也可以放配置文件/openapi-ts.config.ts中,比如:
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: 'http://127.0.0.1:8800/openapi.json', //支持远程url和本地openapi文件
output: './app/APIs',
plugins: [{
name: '@hey-api/client-fetch',
runtimeConfigPath: '@/hey-api', // 控制client.gen.ts生成
},
],
});
4.执行pnpm openapi-ts. 生成的sdk代码都在heyapi目录下,比较复杂。请求方法的代码都生成在sdk.gen.ts,而DTO类型都生成在types.gen.ts中。
app/
├── utils/
│ ├── heyapi/
│ │ ├── client/
│ │ ├── core/
│ │ ├── client.gen.ts
│ │ ├── index.ts
│ │ ├── sdk.gen.ts
│ │ └── types.gen.ts
│ └── index.ts
5.看看是如何使用的吧:
import { postSessions, type LoginDto } from "~/utils/heyapi";
import { client } from "~/utils/heyapi/client.gen";
// 需要先做一些基础的client配置 (也支持自己重新创建一个新 client)
client.setConfig({
baseUrl: "http://127.0.0.1:3000",
});
const body: LoginDto = {
email,
password,
}
const { data, error } = await postSessions({
body: body,
});
if (error) {
setStatus("error");
setMessage(error.message || "登录失败");
} else if (data) {
setStatus("success");
setMessage("登录成功!");
console.log("Login successful:", data);
// 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
// const token = data.data?.accessToken;
}
其中请求结果:data 和 error ,默认情况下 和 openapi-fetch的处理是一致的(都是不抛出错误,而是将错误通过error暴露)。

6.网络请求库方面也适配了fetch和axios,也支持tanstack query。并且计划未来对TS服务端框架支持,但还有很多没完成的(处在soon状态)。
结合 tanstack query
参考:plugin tanstack-query. 准备配置文件/openapi-ts.config.ts:
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: '../默认模块.openapi.json',
output: './app/utils/heyapi',
plugins: ['@tanstack/react-query'],
});
执行 pnpm openapi-ts,此时生成的目录下多出一个文件
./@tanstack/react-query.gen.ts
// This file is auto-generated by @hey-api/openapi-ts
import type { UseMutationOptions } from '@tanstack/react-query';
import { type Options, postSessions, postUsers } from '../sdk.gen';
import type { PostSessionsData, PostSessionsError, PostSessionsResponse, PostUsersData, PostUsersError, PostUsersResponse } from '../types.gen';
/**
* 登录
*/
export const postSessionsMutation = (options?: Partial<Options<PostSessionsData>>): UseMutationOptions<PostSessionsResponse, PostSessionsError, Options<PostSessionsData>> => {
const mutationOptions: UseMutationOptions<PostSessionsResponse, PostSessionsError, Options<PostSessionsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postSessions({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* 注册
*/
export const postUsersMutation = (options?: Partial<Options<PostUsersData>>): UseMutationOptions<PostUsersResponse, PostUsersError, Options<PostUsersData>> => {
const mutationOptions: UseMutationOptions<PostUsersResponse, PostUsersError, Options<PostUsersData>> = {
mutationFn: async (fnOptions) => {
const { data } = await postUsers({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
整个登录页面的代码如下:
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {postSessionsMutation } from "~/utils/heyapi/@tanstack/react-query.gen"
import { postSessions, type LoginDto } from "~/utils/heyapi";
import { client } from "~/utils/heyapi/client.gen";
import type { Route } from "./+types/login3";
// Configure the client
client.setConfig({
baseUrl: "http://127.0.0.1:3000",
});
export function meta({}: Route.MetaArgs) {
return [
{ title: "登录" },
{ name: "description", content: "用户登录" },
];
}
export default function Login() {
const [email, setEmail] = useState("abc@example.com");
const [password, setPassword] = useState("123456");
const { mutate, isPending, isSuccess, error } = useMutation({
// mutationFn: async (body: LoginDto) => {
// const { data, error } = await postSessions({
// body: body,
// });
// if (error) {
// throw error;
// }
// return data;
// },
...postSessionsMutation(),
onSuccess: (data) => {
console.log("Login successful:", data);
// 这里可以处理登录成功后的逻辑,比如保存 token 或跳转
// const token = data.data?.accessToken;
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutate({
body: {
email,
password,
}
});
};
const status = isPending ? "loading" : isSuccess ? "success" : error ? "error" : "idle";
const message = isSuccess
? "登录成功!"
: error
? (error as any).message || "登录失败"
: "";
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8 bg-white p-8 shadow rounded-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
登录您的账户3
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="-space-y-px rounded-md shadow-sm">
<div>
<label htmlFor="email-address" className="sr-only">
邮箱地址
</label>
<input
id="email-address"
name="email"
type="email"
autoComplete="email"
required
className="relative block w-full rounded-t-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
placeholder="邮箱地址"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
密码
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="relative block w-full rounded-b-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
{message && (
<div
className={`text-sm text-center ${
status === "success" ? "text-green-600" : "text-red-600"
}`}
>
{message}
</div>
)}
<div>
<button
type="submit"
disabled={status === "loading"}
className="group relative flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50"
>
{status === "loading" ? "登录中..." : "登录"}
</button>
</div>
</form>
</div>
</div>
);
}
技术选型
技术方案比较:
在选择 TypeScript OpenAPI 客户端生成方案时,核心的权衡点在于:“运行时开销 vs. 开发体验” 以及 “灵活性 vs. 自动化程度”。
| 维度 |
openapi-typescript (+ fetch) |
Orval |
Kubb |
@hey-api/openapi-ts |
| 定位 |
极简/轻量主义 |
前端框架深度集成 |
工业级流水线/全栈体系 |
官方首选/生产级SDK |
| 生成内容 |
仅生成 TS 类型定义 |
类型 + API 请求函数 + Hooks + Mocks |
类型 + Hooks + 验证器 + 路由 + 模版 |
类型 + SDK + Hooks + 验证器 |
| 核心优势 |
零运行时开销。直接利用 TS 类型收窄,包体积增加几乎为 0。 |
TanStack Query 亲儿子。一键生成全套 React/Vue Query 钩子。 |
高度可插件化。支持用 JSX 写代码生成模版,前后端契约高度一致。 |
FastAPI 官方推荐。由原作者维护的升级版,支持 20+ 插件。 |
| 状态管理集成 |
无 (需手动配合 TanStack Query) |
内置支持 TanStack Query, SWR |
内置支持 TanStack Query |
插件支持 TanStack Query, Pinia |
| 校验支持 |
无 (仅编译期) |
支持 Zod |
支持 Zod, Faker
|
支持 Zod, Valibot
|
| 网络库 |
原生 fetch (通过 openapi-fetch) |
Axios, Fetch, Hook |
Axios, Fetch |
Fetch, Axios, Angular, Nuxt |
| Mock 支持 |
无 |
内置支持 MSW
|
内置支持 Faker
|
计划支持 (Chance) |
| 适用场景 |
极度关注包体积、喜欢原生 API、对封装有“洁癖”的项目。 |
典型的中后台管理系统,深度使用 React/Vue Query 的项目。 |
复杂项目,需要自定义生成逻辑(如自动生成后端路由、Schema)的团队。 |
需要高度成熟稳定、符合 FastAPI 体系或大厂规范的 SDK。 |
| 学习曲线 |
极低 |
中 (需配置 orval.config.js) |
高 (需理解插件系统/模版) |
中 |
- 如果你追求 “极致轻量”
-
选择:
openapi-typescript + openapi-fetch
-
理由: 它是目前最符合 TypeScript 原生思维的方案。它不生成成千上万行的 JS 代码,只生成类型。你的 API 调用看起来就像原生的
fetch,但带有完美的自动补全。
- 如果你是 “TanStack Query (React/Vue Query) 用户”
-
选择:
Orval
-
理由: Orval 是目前生成 React Query Hooks 最成熟的工具。它能自动生成
queryKey、处理缓存逻辑、甚至自动生成 MSW 的 Mock 数据,极大提升开发效率。
- 如果你想要 “全栈体系一致性”
-
选择:
Kubb
-
理由: Kubb 的野心更大,它不仅是为了前端。通过它的插件系统,你可以把一套 OpenAPI 定义同时转化为前端的 Hooks 和后端的路由定义(如 Hono/Elysia),确保前后端代码在结构上是“镜像”的。
- 如果你追求 “官方规范与工程化”
-
选择:
@hey-api/openapi-ts
-
理由: 这是
openapi-typescript-codegen 的正统继任者。如果你的后端是 FastAPI,或者你希望生成的代码像一个正式的 SDK(有完整的类、方法封装),它是最稳妥的选择。它的插件系统(Plugin)也让它在功能上非常全能。
总结一句话:
- 想简单:
openapi-typescript
- 想省事(前端):
Orval
- 想折腾/全栈:
Kubb
- 想标准/大而全:
Hey API
我个人的话,就主要从hey-api何openapi-typescript/openapi-fetch中选了:
- 对于管理端、ToB的应用,使用
hey-api 或者 Orval
- 对于比较轻量化的h5页面使用
openapi-typescript/openapi-fetch