type-safe-api
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseType-Safe API Core Knowledge
Type-Safe API 核心知识
Deep Knowledge: Usewith technology:mcp__documentation__fetch_docsfor comprehensive documentation.type-safe-api
深度参考:使用工具并指定技术类型为mcp__documentation__fetch_docs,可获取完整文档。type-safe-api
Zod to OpenAPI
Zod 转 OpenAPI
Generate OpenAPI specs from Zod schemas for type-first development.
bash
npm install @asteasolutions/zod-to-openapi zod从Zod Schema生成OpenAPI规范,实现类型优先的开发模式。
bash
npm install @asteasolutions/zod-to-openapi zodDefine Schemas
定义Schema
typescript
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
extendZodWithOpenApi(z);
// Schema with OpenAPI metadata
export const UserSchema = z.object({
id: z.string().openapi({ example: 'user_123' }),
name: z.string().min(1).openapi({ example: 'John Doe' }),
email: z.string().email().openapi({ example: 'john@example.com' }),
role: z.enum(['user', 'admin']).openapi({ example: 'user' }),
createdAt: z.date().openapi({ example: '2024-01-01T00:00:00Z' }),
}).openapi('User');
export const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true })
.openapi('CreateUser');
export type User = z.infer<typeof UserSchema>;
export type CreateUser = z.infer<typeof CreateUserSchema>;typescript
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
extendZodWithOpenApi(z);
// Schema with OpenAPI metadata
export const UserSchema = z.object({
id: z.string().openapi({ example: 'user_123' }),
name: z.string().min(1).openapi({ example: 'John Doe' }),
email: z.string().email().openapi({ example: 'john@example.com' }),
role: z.enum(['user', 'admin']).openapi({ example: 'user' }),
createdAt: z.date().openapi({ example: '2024-01-01T00:00:00Z' }),
}).openapi('User');
export const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true })
.openapi('CreateUser');
export type User = z.infer<typeof UserSchema>;
export type CreateUser = z.infer<typeof CreateUserSchema>;Generate OpenAPI Document
生成OpenAPI文档
typescript
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
const registry = new OpenAPIRegistry();
// Register schemas
registry.register('User', UserSchema);
registry.register('CreateUser', CreateUserSchema);
// Register endpoints
registry.registerPath({
method: 'get',
path: '/users/{id}',
summary: 'Get user by ID',
request: {
params: z.object({ id: z.string() }),
},
responses: {
200: {
description: 'User found',
content: {
'application/json': { schema: UserSchema },
},
},
404: {
description: 'User not found',
},
},
});
registry.registerPath({
method: 'post',
path: '/users',
summary: 'Create user',
request: {
body: {
content: {
'application/json': { schema: CreateUserSchema },
},
},
},
responses: {
201: {
description: 'User created',
content: {
'application/json': { schema: UserSchema },
},
},
},
});
// Generate OpenAPI document
const generator = new OpenApiGeneratorV3(registry.definitions);
const openApiDocument = generator.generateDocument({
openapi: '3.0.0',
info: {
title: 'User API',
version: '1.0.0',
},
servers: [{ url: 'https://api.example.com' }],
});typescript
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
const registry = new OpenAPIRegistry();
// Register schemas
registry.register('User', UserSchema);
registry.register('CreateUser', CreateUserSchema);
// Register endpoints
registry.registerPath({
method: 'get',
path: '/users/{id}',
summary: 'Get user by ID',
request: {
params: z.object({ id: z.string() }),
},
responses: {
200: {
description: 'User found',
content: {
'application/json': { schema: UserSchema },
},
},
404: {
description: 'User not found',
},
},
});
registry.registerPath({
method: 'post',
path: '/users',
summary: 'Create user',
request: {
body: {
content: {
'application/json': { schema: CreateUserSchema },
},
},
},
responses: {
201: {
description: 'User created',
content: {
'application/json': { schema: UserSchema },
},
},
},
});
// Generate OpenAPI document
const generator = new OpenApiGeneratorV3(registry.definitions);
const openApiDocument = generator.generateDocument({
openapi: '3.0.0',
info: {
title: 'User API',
version: '1.0.0',
},
servers: [{ url: 'https://api.example.com' }],
});ts-rest (Contract-First)
ts-rest(契约优先)
Type-safe REST API contracts shared between client and server.
bash
npm install @ts-rest/core
npm install @ts-rest/next # For Next.js
npm install @ts-rest/react-query # For React Query客户端与服务器之间共享的类型安全REST API契约。
bash
npm install @ts-rest/core
npm install @ts-rest/next # For Next.js
npm install @ts-rest/react-query # For React QueryDefine Contract
定义契约
typescript
// contracts/api.ts
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const c = initContract();
export const userContract = c.router({
getUser: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
404: z.object({ message: z.string() }),
},
},
createUser: {
method: 'POST',
path: '/users',
body: z.object({
name: z.string(),
email: z.string().email(),
}),
responses: {
201: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
400: z.object({ message: z.string() }),
},
},
listUsers: {
method: 'GET',
path: '/users',
query: z.object({
page: z.number().optional(),
limit: z.number().optional(),
}),
responses: {
200: z.array(z.object({
id: z.string(),
name: z.string(),
email: z.string(),
})),
},
},
});typescript
// contracts/api.ts
import { initContract } from '@ts-rest/core';
import { z } from 'zod';
const c = initContract();
export const userContract = c.router({
getUser: {
method: 'GET',
path: '/users/:id',
pathParams: z.object({ id: z.string() }),
responses: {
200: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
404: z.object({ message: z.string() }),
},
},
createUser: {
method: 'POST',
path: '/users',
body: z.object({
name: z.string(),
email: z.string().email(),
}),
responses: {
201: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
400: z.object({ message: z.string() }),
},
},
listUsers: {
method: 'GET',
path: '/users',
query: z.object({
page: z.number().optional(),
limit: z.number().optional(),
}),
responses: {
200: z.array(z.object({
id: z.string(),
name: z.string(),
email: z.string(),
})),
},
},
});Server Implementation (Next.js)
服务器实现(Next.js)
typescript
// pages/api/[...ts-rest].ts
import { createNextRoute, createNextRouter } from '@ts-rest/next';
import { userContract } from '../../contracts/api';
const router = createNextRouter(userContract, {
getUser: async ({ params }) => {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) {
return { status: 404, body: { message: 'Not found' } };
}
return { status: 200, body: user };
},
createUser: async ({ body }) => {
const user = await db.user.create({ data: body });
return { status: 201, body: user };
},
listUsers: async ({ query }) => {
const users = await db.user.findMany({
skip: ((query.page ?? 1) - 1) * (query.limit ?? 10),
take: query.limit ?? 10,
});
return { status: 200, body: users };
},
});
export default createNextRoute(userContract, router);typescript
// pages/api/[...ts-rest].ts
import { createNextRoute, createNextRouter } from '@ts-rest/next';
import { userContract } from '../../contracts/api';
const router = createNextRouter(userContract, {
getUser: async ({ params }) => {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) {
return { status: 404, body: { message: 'Not found' } };
}
return { status: 200, body: user };
},
createUser: async ({ body }) => {
const user = await db.user.create({ data: body });
return { status: 201, body: user };
},
listUsers: async ({ query }) => {
const users = await db.user.findMany({
skip: ((query.page ?? 1) - 1) * (query.limit ?? 10),
take: query.limit ?? 10,
});
return { status: 200, body: users };
},
});
export default createNextRoute(userContract, router);Client Usage
客户端使用
typescript
// lib/api-client.ts
import { initClient } from '@ts-rest/core';
import { userContract } from '../contracts/api';
export const apiClient = initClient(userContract, {
baseUrl: 'https://api.example.com',
baseHeaders: {
Authorization: `Bearer ${getToken()}`,
},
});
// Usage (fully typed)
const { body: user, status } = await apiClient.getUser({ params: { id: '123' } });
const { body: newUser } = await apiClient.createUser({
body: { name: 'John', email: 'john@example.com' },
});typescript
// lib/api-client.ts
import { initClient } from '@ts-rest/core';
import { userContract } from '../contracts/api';
export const apiClient = initClient(userContract, {
baseUrl: 'https://api.example.com',
baseHeaders: {
Authorization: `Bearer ${getToken()}`,
},
});
// Usage (fully typed)
const { body: user, status } = await apiClient.getUser({ params: { id: '123' } });
const { body: newUser } = await apiClient.createUser({
body: { name: 'John', email: 'john@example.com' },
});React Query Integration
React Query集成
typescript
import { initQueryClient } from '@ts-rest/react-query';
import { userContract } from '../contracts/api';
const client = initQueryClient(userContract, {
baseUrl: 'https://api.example.com',
});
// In component
function UserProfile({ id }: { id: string }) {
const { data, isLoading } = client.getUser.useQuery(
['user', id],
{ params: { id } }
);
if (isLoading) return <Spinner />;
return <div>{data?.body.name}</div>;
}typescript
import { initQueryClient } from '@ts-rest/react-query';
import { userContract } from '../contracts/api';
const client = initQueryClient(userContract, {
baseUrl: 'https://api.example.com',
});
// In component
function UserProfile({ id }: { id: string }) {
const { data, isLoading } = client.getUser.useQuery(
['user', id],
{ params: { id } }
);
if (isLoading) return <Spinner />;
return <div>{data?.body.name}</div>;
}Zodios (Type-Safe REST Client)
Zodios(类型安全REST客户端)
bash
npm install @zodios/core zod
npm install @zodios/react # For React hooksbash
npm install @zodios/core zod
npm install @zodios/react # For React hooksDefine API
定义API
typescript
import { makeApi, Zodios } from '@zodios/core';
import { z } from 'zod';
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
const api = makeApi([
{
method: 'get',
path: '/users/:id',
alias: 'getUser',
response: userSchema,
parameters: [
{ type: 'Path', name: 'id', schema: z.string() },
],
},
{
method: 'post',
path: '/users',
alias: 'createUser',
response: userSchema,
parameters: [
{
type: 'Body',
name: 'body',
schema: z.object({
name: z.string(),
email: z.string().email(),
}),
},
],
},
{
method: 'get',
path: '/users',
alias: 'listUsers',
response: z.array(userSchema),
parameters: [
{ type: 'Query', name: 'status', schema: z.string().optional() },
],
},
]);
export const apiClient = new Zodios('https://api.example.com', api);typescript
import { makeApi, Zodios } from '@zodios/core';
import { z } from 'zod';
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
const api = makeApi([
{
method: 'get',
path: '/users/:id',
alias: 'getUser',
response: userSchema,
parameters: [
{ type: 'Path', name: 'id', schema: z.string() },
],
},
{
method: 'post',
path: '/users',
alias: 'createUser',
response: userSchema,
parameters: [
{
type: 'Body',
name: 'body',
schema: z.object({
name: z.string(),
email: z.string().email(),
}),
},
],
},
{
method: 'get',
path: '/users',
alias: 'listUsers',
response: z.array(userSchema),
parameters: [
{ type: 'Query', name: 'status', schema: z.string().optional() },
],
},
]);
export const apiClient = new Zodios('https://api.example.com', api);Client Usage
客户端使用
typescript
// Fully typed
const user = await apiClient.getUser({ params: { id: '123' } });
const users = await apiClient.listUsers({ queries: { status: 'active' } });
const newUser = await apiClient.createUser({
name: 'John',
email: 'john@example.com',
});typescript
// Fully typed
const user = await apiClient.getUser({ params: { id: '123' } });
const users = await apiClient.listUsers({ queries: { status: 'active' } });
const newUser = await apiClient.createUser({
name: 'John',
email: 'john@example.com',
});Contract Testing
契约测试
With Pact
使用Pact
bash
npm install -D @pact-foundation/pacttypescript
import { Pact } from '@pact-foundation/pact';
const provider = new Pact({
consumer: 'Frontend',
provider: 'UserAPI',
});
describe('User API Contract', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
it('should get user by id', async () => {
await provider.addInteraction({
state: 'user with id 123 exists',
uponReceiving: 'a request to get user 123',
withRequest: {
method: 'GET',
path: '/users/123',
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: '123',
name: 'John Doe',
email: 'john@example.com',
},
},
});
const user = await apiClient.getUser({ params: { id: '123' } });
expect(user.name).toBe('John Doe');
});
});bash
npm install -D @pact-foundation/pacttypescript
import { Pact } from '@pact-foundation/pact';
const provider = new Pact({
consumer: 'Frontend',
provider: 'UserAPI',
});
describe('User API Contract', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
afterEach(() => provider.verify());
it('should get user by id', async () => {
await provider.addInteraction({
state: 'user with id 123 exists',
uponReceiving: 'a request to get user 123',
withRequest: {
method: 'GET',
path: '/users/123',
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: '123',
name: 'John Doe',
email: 'john@example.com',
},
},
});
const user = await apiClient.getUser({ params: { id: '123' } });
expect(user.name).toBe('John Doe');
});
});Production Readiness
生产就绪建议
Shared Types Strategy (Monorepo)
共享类型策略(Monorepo)
packages/
├── api-contracts/ # Shared contracts
│ ├── src/
│ │ ├── schemas.ts # Zod schemas
│ │ ├── types.ts # TypeScript types
│ │ └── contract.ts # ts-rest contract
│ └── package.json
├── backend/
│ ├── src/
│ │ └── routes/ # Implements contracts
│ └── package.json
└── frontend/
├── src/
│ └── api/ # Uses contracts
└── package.jsonpackages/
├── api-contracts/ # 共享契约
│ ├── src/
│ │ ├── schemas.ts # Zod schema
│ │ ├── types.ts # TypeScript类型
│ │ └── contract.ts # ts-rest契约
│ └── package.json
├── backend/
│ ├── src/
│ │ └── routes/ # 实现契约
│ └── package.json
└── frontend/
├── src/
│ └── api/ # 使用契约
└── package.jsonBreaking Change Detection
破坏性变更检测
typescript
// scripts/check-breaking-changes.ts
import { diff } from 'json-diff';
import oldSpec from './openapi-old.json';
import newSpec from './openapi-new.json';
const changes = diff(oldSpec, newSpec);
const breaking = findBreakingChanges(changes);
if (breaking.length > 0) {
console.error('Breaking changes detected:');
breaking.forEach(console.error);
process.exit(1);
}typescript
// scripts/check-breaking-changes.ts
import { diff } from 'json-diff';
import oldSpec from './openapi-old.json';
import newSpec from './openapi-new.json';
const changes = diff(oldSpec, newSpec);
const breaking = findBreakingChanges(changes);
if (breaking.length > 0) {
console.error('Breaking changes detected:');
breaking.forEach(console.error);
process.exit(1);
}Checklist
检查清单
- Shared schema package in monorepo
- OpenAPI spec generated from schemas
- Contract tests between services
- Breaking change detection in CI
- Type generation automated
- Runtime validation on boundaries
- Error types included in contracts
- Versioning strategy defined
- Monorepo中包含共享Schema包
- 从Schema生成OpenAPI规范
- 服务间配置契约测试
- CI中集成破坏性变更检测
- 自动生成类型
- 边界处配置运行时校验
- 契约中包含错误类型
- 定义版本化策略
When NOT to Use This Skill
不适用场景
- tRPC projects (use skill - simpler for full-stack TypeScript)
trpc - GraphQL APIs (use skill)
graphql - Simple REST APIs without shared types (use instead)
openapi-codegen - Non-TypeScript projects
- Microservices with different languages
- Public APIs consumed by third parties (OpenAPI spec better)
- tRPC项目(请使用方案,对全栈TypeScript更友好)
trpc - GraphQL API(请使用方案)
graphql - 无需共享类型的简单REST API(请改用)
openapi-codegen - 非TypeScript项目
- 使用不同语言的微服务
- 供第三方调用的公开API(OpenAPI规范更合适)
Anti-Patterns
反模式
| Anti-Pattern | Why It's Bad | Solution |
|---|---|---|
| Sharing database entities as API types | Leaks implementation, tight coupling | Create separate DTOs/schemas |
| No runtime validation | Type safety only at compile time | Use Zod for runtime validation |
| Duplicating schemas between packages | Maintenance burden, drift risk | Use shared schema package in monorepo |
| Not versioning shared types | Breaking changes affect all consumers | Version shared package, use semver |
| Missing contract tests | Types match but behavior doesn't | Implement Pact or similar contract testing |
| Mixing type-safety approaches | Complexity, inconsistency | Choose one approach (tRPC, ts-rest, or Zod-OpenAPI) |
| No breaking change detection | Silent failures in production | Add schema diff checking in CI |
| Hardcoding types instead of generating | Manual sync burden | Generate from single source of truth |
| 反模式 | 危害 | 解决方案 |
|---|---|---|
| 将数据库实体作为API类型共享 | 暴露实现细节,导致强耦合 | 创建独立的DTO/Schema |
| 无运行时校验 | 仅在编译时保证类型安全 | 使用Zod进行运行时校验 |
| 在多个包中重复定义Schema | 维护成本高,存在类型漂移风险 | 在Monorepo中使用共享Schema包 |
| 不对共享类型进行版本化 | 破坏性变更会影响所有消费者 | 对共享包进行版本化,使用语义化版本控制 |
| 缺少契约测试 | 类型匹配但行为不一致 | 实现Pact或类似的契约测试 |
| 混合使用多种类型安全方案 | 复杂度高,一致性差 | 选择一种方案(tRPC、ts-rest或Zod-OpenAPI) |
| 无破坏性变更检测 | 生产环境中出现静默故障 | 在CI中添加Schema差异检查 |
| 硬编码类型而非自动生成 | 手动同步成本高 | 从单一可信源生成类型 |
Quick Troubleshooting
快速故障排查
| Issue | Possible Cause | Solution |
|---|---|---|
| Type mismatches between FE/BE | Shared types not updated | Regenerate types, check imports |
| Runtime validation fails | Request doesn't match schema | Check request payload, update schema |
| Contract tests failing | API behavior changed | Update contract or fix API implementation |
| Circular dependency errors | Frontend importing backend code | Use separate shared types package |
| Breaking changes not detected | No schema diffing | Add schema versioning and diff tool |
| Schema generation fails | Invalid Zod schema | Check schema syntax, validate with Zod |
| OpenAPI spec out of sync | Manual spec edits | Generate spec from Zod schemas |
| Type inference not working | Wrong import or export | Verify type exports from shared package |
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 前后端类型不匹配 | 共享类型未更新 | 重新生成类型,检查导入路径 |
| 运行时校验失败 | 请求不符合Schema | 检查请求 payload,更新Schema |
| 契约测试失败 | API行为变更 | 更新契约或修复API实现 |
| 循环依赖错误 | 前端导入后端代码 | 使用独立的共享类型包 |
| 未检测到破坏性变更 | 未配置Schema差异检查 | 添加Schema版本化和差异工具 |
| Schema生成失败 | Zod Schema无效 | 检查Schema语法,使用Zod验证 |
| OpenAPI规范不同步 | 手动修改了规范 | 从Zod Schema自动生成规范 |
| 类型推断不生效 | 导入或导出错误 | 验证共享包中的类型导出 |
Reference Documentation
参考文档
- Zod to OpenAPI
- ts-rest
- Contract Testing
- Zod to OpenAPI
- ts-rest
- Contract Testing