当前位置: 首页 > news >正文

ts-rest:基于TypeScript契约实现端到端类型安全的REST API开发

1. 从“类型地狱”到“魔法体验”:为什么我们需要 ts-rest?

如果你和我一样,长期在 TypeScript 全栈项目中摸爬滚打,肯定对下面这个场景深恶痛绝:前端调用一个/api/posts接口,你小心翼翼地在后端定义了Post类型,然后在前端手动再敲一遍几乎一模一样的interface Post。这还没完,当后端接口的响应结构、查询参数或者请求头发生一丁点变化时,你必须在两个、甚至多个地方同步更新,稍有不慎,运行时错误就会悄然而至。这种“伪类型安全”带来的虚假安全感,比没有类型更可怕。我们渴望的,是真正的、从数据库到 UI 的端到端类型安全。

过去,我们尝试过各种方案。Swagger/OpenAPI 配合代码生成器?配置繁琐,生成的代码往往不够优雅,且存在“契约漂移”的风险——文档和实际代码不同步。GraphQL?它很棒,但学习曲线陡峭,对简单的 CRUD 项目来说可能杀鸡用牛刀,而且缓存、文件上传等场景处理起来并不总是那么顺手。我们需要的,是一个能像 RPC(远程过程调用)一样简单调用,又能保持 RESTful 风格和灵活性的东西,最好还不用引入额外的代码生成步骤。

这就是ts-rest诞生的背景。它不是另一个臃肿的框架,而是一套轻量级的“契约”工具。它的核心思想极其简单:在一个地方(通常是共享的 TypeScript 文件)定义你的 API 契约,然后这个契约会同时为你的服务器实现和客户端消费提供严格的类型约束。没有代码生成,类型检查完全在编译时完成,享受的是 TypeScript 原生类型推导带来的丝滑体验。它像胶水一样,把前后端松散的类型定义牢牢粘合在一起,实现了开发者梦寐以求的“一次定义,处处安全”。对于使用 Fastify、NestJS、Next.js 等主流 Node.js 框架,以及 React、Solid 等前端框架的团队来说,ts-rest 提供了一条渐进式采纳的优雅路径,让你从第一个接口开始,就能告别类型同步的噩梦。

2. 核心设计解析:契约如何成为“唯一真相源”?

ts-rest 的魔力全部源于其精心设计的“契约”(Contract)概念。理解这一点,是掌握它的关键。你可以把契约想象成你和团队(或者前后端)签订的一份具有法律效力的 API 协议,而 TypeScript 编译器就是最严格的法官。

2.1 契约的结构:不止是路径和方法

一个 ts-rest 契约的核心是c.router函数。它接受一个对象,对象的每个属性代表一个 API 端点。每个端点的定义远不止 HTTP 方法和路径那么简单,它是一个完整的类型描述单元:

import { initContract } from '@ts-rest/core'; import { z } from 'zod'; const c = initContract(); export const postsContract = c.router({ // 端点1:获取文章列表 getPosts: { method: 'GET', path: '/posts', query: z.object({ // 查询参数验证与类型 page: z.number().int().positive().default(1), limit: z.number().int().min(1).max(100).default(20), category: z.string().optional(), }), responses: { // 响应状态码与体类型映射 200: z.object({ posts: z.array(z.object({ id: z.string(), title: z.string(), content: z.string(), createdAt: z.string().datetime(), })), total: z.number(), }), 400: z.object({ error: z.string() }), // 清晰的错误类型 500: c.type<{ message: string }>(), // 也可以使用纯TS类型 }, summary: '获取文章分页列表', // 对生成OpenAPI文档友好 description: '根据分页和分类参数查询文章', }, // 端点2:创建文章 createPost: { method: 'POST', path: '/posts', body: z.object({ // 请求体验证与类型 title: z.string().min(1).max(200), content: z.string().min(1), categoryId: z.string().uuid(), }), responses: { 201: z.object({ id: z.string(), title: z.string() }), 401: z.object({ error: z.literal('Unauthorized') }), }, }, });

为什么这样设计?

  1. 集中化管理:所有 API 的输入(pathquerybodyheaders)、输出(responses)和行为(method)都在一个地方定义。这是“唯一真相源”原则的体现。
  2. Zod 深度集成:ts-rest 首选 Zod 作为模式验证库。Zod 不仅能定义运行时验证规则(如.min(1)),还能自动推断出极其精确的 TypeScript 类型。这意味着你的验证逻辑和类型定义是同源的,彻底杜绝了不一致。
  3. 响应多态化responses对象允许你为不同的 HTTP 状态码定义不同的响应体类型。这强迫开发者更严谨地思考 API 的各种返回情况(成功、客户端错误、服务端错误),并在客户端就能安全地处理这些不同的类型分支。
  4. 渐进式增强:你可以从最简单的c.type<T>()开始,只做类型描述。当需要运行时验证时,无缝切换到 Zod Schema。这种灵活性降低了上手门槛。

2.2 类型流转:从契约到客户端与服务器

定义好契约后,ts-rest 会利用 TypeScript 的泛型和条件类型,将这份契约“翻译”成客户端和服务器所需的精确类型。

  • 服务器端:当你使用s.router(contract, { ... })时,第二个参数中的每个处理函数,其参数(params,query,body,headers)和返回值类型,都会自动被契约约束。如果你尝试返回一个不符合responses[200]定义的结构,TypeScript 会在编码阶段就报错。
  • 客户端端:通过initClient(contract, { ... })创建的客户端对象,其每个方法(如client.getPosts)的调用参数和返回值类型,也完全由契约决定。你甚至能获得路径参数和查询参数的自动补全。

这种设计带来的最大好处是“破坏性变更立即暴露”。如果后端修改了query的参数名,前端调用处的代码会立刻出现类型错误。这比运行时的 400 Bad Request 要友好和高效得多。

3. 实战:从零搭建一个类型安全的博客 API

理论说得再多,不如动手实践。让我们用 ts-rest 快速搭建一个博客系统的核心 API,涵盖 CRUD 操作,并集成到 Next.js App Router 和 React 前端中。

3.1 项目初始化与契约定义

首先,创建一个新项目并安装核心依赖。

mkdir ts-rest-blog && cd ts-rest-blog npm init -y npm install typescript @types/node --save-dev npm install @ts-rest/core @ts-rest/express @ts-rest/react-query zod npm install express npx tsc --init

接下来,我们定义共享的契约。我习惯在项目根目录或一个独立的packages/contract中管理它。

文件:contracts/posts.ts

import { initContract } from '@ts-rest/core'; import { z } from 'zod'; // 定义一些通用的、可复用的 Schema const IdSchema = z.string().uuid(); const ErrorSchema = z.object({ message: z.string() }); // 初始化契约实例 const c = initContract(); // 文章基础 Schema export const PostSchema = z.object({ id: IdSchema, title: z.string().min(1).max(200), content: z.string().min(1), published: z.boolean().default(false), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); // 创建文章的请求体 Schema export const CreatePostSchema = PostSchema.pick({ title: true, content: true }).extend({ categoryId: IdSchema.optional(), }); // 更新文章的请求体 Schema (Partial) export const UpdatePostSchema = CreatePostSchema.partial(); // 核心 API 契约 export const postsContract = c.router({ // 获取所有文章 (带分页和过滤) getPosts: { method: 'GET', path: '/posts', query: z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(50).default(10), published: z.coerce.boolean().optional(), categoryId: IdSchema.optional(), }), responses: { 200: z.object({ posts: z.array(PostSchema), pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), totalPages: z.number(), }), }), 400: ErrorSchema, }, summary: '获取文章列表', }, // 获取单篇文章 getPost: { method: 'GET', path: '/posts/:id', pathParams: z.object({ id: IdSchema }), responses: { 200: PostSchema, 404: ErrorSchema, }, summary: '根据ID获取文章', }, // 创建文章 createPost: { method: 'POST', path: '/posts', body: CreatePostSchema, responses: { 201: PostSchema, 401: ErrorSchema, 422: z.object({ errors: z.record(z.array(z.string())) }), // 验证错误,例如使用 class-validator }, summary: '创建新文章', }, // 更新文章 updatePost: { method: 'PATCH', // 使用 PATCH 进行部分更新 path: '/posts/:id', pathParams: z.object({ id: IdSchema }), body: UpdatePostSchema, responses: { 200: PostSchema, 404: ErrorSchema, 422: ErrorSchema, }, summary: '更新文章', }, // 删除文章 deletePost: { method: 'DELETE', path: '/posts/:id', pathParams: z.object({ id: IdSchema }), responses: { 204: c.type<null>(), // 204 No Content 404: ErrorSchema, }, summary: '删除文章', }, }, { pathPrefix: '/api/v1', // 为所有路由添加前缀 strictStatusCodes: true, // 强制处理函数返回声明的状态码 });

注意:这里我使用了z.coerce。这是一个非常实用的 Zod 特性,它允许你将传入的字符串(HTTP 请求参数默认都是字符串)自动转换为数字或布尔值。例如,?page=2&published=true中的"2""true"会被自动转换为2true,省去了手动转换的麻烦。

3.2 服务器端实现(以 Express 为例)

有了契约,实现服务器端就变成了“填空”游戏。我们使用@ts-rest/express适配器。

文件:server/index.ts

import express from 'express'; import { initServer } from '@ts-rest/express'; import { postsContract, PostSchema, CreatePostSchema } from '../contracts/posts'; import * as db from './db'; // 假设的数据库模块 const app = express(); app.use(express.json()); // 解析 JSON body // 1. 初始化 ts-rest Express 服务器 const s = initServer(); // 2. 创建符合契约的路由器 const postsRouter = s.router(postsContract, { getPosts: async ({ query }) => { const { page, limit, published, categoryId } = query; // 模拟数据库查询 const { data: posts, total } = await db.findPosts({ skip: (page - 1) * limit, take: limit, where: { published, categoryId }, }); return { status: 200, body: { posts, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }, }; }, getPost: async ({ params }) => { const post = await db.findPostById(params.id); if (!post) { return { status: 404, body: { message: 'Post not found' } }; } return { status: 200, body: post }; }, createPost: async ({ body, req }) => { // 这里可以访问原生的 Express req 对象,用于鉴权等 // const userId = req.user.id; try { // 数据已由 ts-rest 根据 Zod Schema 验证过 const newPost = await db.createPost({ ...body, authorId: 'user-123', // 从 session 中获取 }); return { status: 201, body: newPost }; } catch (error) { // 处理可能的数据库约束错误等 return { status: 422, body: { message: 'Validation failed', errors: error } }; } }, updatePost: async ({ params, body }) => { const updated = await db.updatePost(params.id, body); if (!updated) { return { status: 404, body: { message: 'Post not found' } }; } return { status: 200, body: updated }; }, deletePost: async ({ params }) => { const deleted = await db.deletePost(params.id); if (!deleted) { return { status: 404, body: { message: 'Post not found' } }; } return { status: 204, body: null }; }, }); // 3. 将路由器注册到 Express app app.use(s.expressRouter(postsRouter)); // 4. 全局错误处理(可选但推荐) app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { console.error(err); res.status(500).json({ message: 'Internal server error' }); }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });

实操心得

  • 类型安全贯穿始终:在createPost处理函数中,body的类型已经是CreatePostSchema推断出的{ title: string; content: string; categoryId?: string },你不可能错误地访问一个不存在的属性。
  • 原生对象访问@ts-rest/express适配器会将原生的 Expressreqres对象作为第二个参数传入处理函数(示例中{ body, req }),方便你进行更底层的操作,如会话管理、设置 cookie 等。
  • 错误处理:ts-rest 不强制你如何返回错误,它只关心类型。你可以根据契约返回400404422等,这给了你充分的灵活性去设计自己的错误响应格式。

3.3 客户端集成(以 React + React Query 为例)

前端消费 API 是 ts-rest 体验最好的部分。我们使用@ts-rest/react-query,它能与 TanStack Query (React Query) 无缝集成,提供自动的类型化查询和突变。

文件:frontend/src/lib/api-client.ts

import { initClient } from '@ts-rest/core'; import { initQueryClient } from '@ts-rest/react-query'; import { postsContract } from '../../../contracts/posts'; // 导入共享的契约 // 1. 创建基础 HTTP 客户端 const baseClient = initClient(postsContract, { baseUrl: 'http://localhost:3000/api/v1', baseHeaders: { 'Content-Type': 'application/json', }, // 可以在这里添加全局拦截器,例如添加认证 token // api: async (args) => { // const token = localStorage.getItem('token'); // args.headers = { ...args.headers, Authorization: `Bearer ${token}` }; // return fetch(args); // }, }); // 2. 创建 React Query 集成客户端 export const apiClient = initQueryClient(baseClient, { // 全局的 React Query 配置 queryClientConfig: { defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5分钟 retry: 1, }, }, }, });

文件:frontend/src/components/PostList.tsx

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '../lib/api-client'; export function PostList() { const queryClient = useQueryClient(); const [page, setPage] = useState(1); // 类型安全的查询!`data` 的类型自动来自契约的 `responses[200]` const { data, isLoading, error } = apiClient.getPosts.useQuery( ['posts', { page }], // Query Key { query: { page, limit: 10 } } // 参数完全类型安全 ); // 类型安全的突变 const createMutation = apiClient.createPost.useMutation({ onSuccess: () => { // 创建成功后,使文章列表查询失效,触发重新获取 queryClient.invalidateQueries(['posts']); }, }); const handleCreate = () => { createMutation.mutate({ body: { title: 'My New Post', content: 'This is the content...', // categoryId: '...' // 可选,不传也没问题,类型是 `string | undefined` }, }); }; if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> <button onClick={handleCreate} disabled={createMutation.isLoading}> {createMutation.isLoading ? 'Creating...' : 'Create Post'} </button> <ul> {data?.body.posts.map((post) => ( <li key={post.id}> <h3>{post.title}</h3> <p>{post.content.slice(0, 100)}...</p> <small>Created at: {new Date(post.createdAt).toLocaleDateString()}</small> </li> ))} </ul> {/* 分页控件 */} <div> <button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}> Previous </button> <span> Page {page} of {data?.body.pagination.totalPages} </span> <button onClick={() => setPage(p => p + 1)} disabled={page >= (data?.body.pagination.totalPages || 1)}> Next </button> </div> </div> ); }

为什么这样用?

  • 极佳的开发者体验apiClient.getPosts.useQuery返回的data类型是精确的{ posts: Post[], pagination: {...} }。编辑器能提供完美的自动补全和类型检查。
  • 参数安全:调用useQuerymutate时,你必须传入符合契约定义的querybodyheaders对象。传错字段或类型不匹配,编译时就会报错。
  • 与 React Query 生态完美融合:你仍然可以使用 React Query 的所有强大功能,如缓存、重试、依赖查询等,只是现在它们都是完全类型安全的。

4. 进阶技巧与生态集成

ts-rest 的威力不止于基础 CRUD。它在复杂场景和现代开发栈中同样游刃有余。

4.1 嵌套路由与模块化契约

大型项目需要模块化。ts-rest 的契约可以像乐高一样组合。

// contracts/users.ts export const usersContract = c.router({ getProfile: { method: 'GET', path: '/profile', responses: { 200: UserSchema } }, updateProfile: { method: 'PUT', path: '/profile', body: UpdateUserSchema, responses: { 200: UserSchema } }, }); // contracts/comments.ts export const commentsContract = c.router({ getCommentsForPost: { method: 'GET', path: '/posts/:postId/comments', ... }, createComment: { method: 'POST', path: '/posts/:postId/comments', ... }, }); // contracts/index.ts - 合并根契约 import { postsContract } from './posts'; import { usersContract } from './users'; import { commentsContract } from './comments'; export const appContract = c.router({ posts: postsContract, users: usersContract, comments: commentsContract, health: { method: 'GET', path: '/health', responses: { 200: c.type<{ status: 'ok' }>() } }, });

在服务器端,你可以分别实现每个子路由器,然后合并注册。

// server/routers/posts.ts export const postsRouter = s.router(postsContract, { ... }); // server/routers/users.ts export const usersRouter = s.router(usersContract, { ... }); // server/index.ts import { postsRouter, usersRouter } from './routers'; const appRouter = s.router(appContract, { posts: postsRouter, users: usersRouter, // comments 可以后续再实现,只要类型匹配即可 comments: s.router(commentsContract, { ... }), health: async () => ({ status: 200, body: { status: 'ok' } }), }); app.use(s.expressRouter(appRouter));

4.2 与 NestJS、Next.js 等框架深度集成

ts-rest 提供了官方适配器,让你能在主流框架中享受原生体验。

在 NestJS 中使用:

npm install @ts-rest/nest
// posts.controller.ts import { Controller, Get, Param, Query } from '@nestjs/common'; import { TsRestHandler, tsRestHandler } from '@ts-rest/nest'; import { postsContract } from 'contracts'; import { PostsService } from './posts.service'; @Controller() export class PostsController { constructor(private readonly postsService: PostsService) {} @TsRestHandler(postsContract.getPosts) async getPosts(@Query() query) { // query 的类型是自动推断的 return tsRestHandler(postsContract.getPosts, async ({ query }) => { const result = await this.postsService.findAll(query); return { status: 200, body: result }; }); } // 或者使用更简洁的装饰器方式(需要适配器支持) @TsRestHandler(postsContract) async handler() { return { getPosts: async ({ query }) => ({ ... }), getPost: async ({ params }) => ({ ... }), }; } }

在 Next.js App Router 中使用:Next.js 的 App Router 倡导使用 TypeScript 和 Server Actions。ts-rest 可以完美地作为你的“类型安全后端”与 Next.js Server Components 或 Route Handlers 结合。

// app/api/posts/route.ts - Next.js Route Handler import { initServer } from '@ts-rest/next'; import { postsContract } from '../../../../contracts/posts'; import { db } from '@/lib/db'; const s = initServer(); const router = s.router(postsContract, { getPosts: async ({ query }) => { // 实现... }, // ... 其他端点 }); // 分别导出 GET, POST 等处理方法 export const GET = router.getPosts; export const POST = router.createPost; // 注意:需要根据 ts-rest 的适配器调整,可能需要一个包装函数

更常见的模式是,在 Next.js 全栈项目中,你可以在app目录外定义共享契约,然后在app/api的 Route Handlers 和app内的 Server Components 中同时导入使用,确保前后端类型一致。

4.3 生成 OpenAPI 文档

虽然 ts-rest 的核心优势在于类型而非文档,但它也提供了可选的 OpenAPI 集成,让你“鱼与熊掌兼得”。

npm install @ts-rest/open-api
import { generateOpenApi } from '@ts-rest/open-api'; import { appContract } from './contracts'; import { createDocument } from '@ts-rest/open-api'; const openApiDocument = generateOpenApi(appContract, { info: { title: 'Blog API', version: '1.0.0', description: 'A type-safe blog API built with ts-rest', }, servers: [{ url: 'http://localhost:3000/api/v1' }], }, { setOperationId: true, // 为每个操作生成唯一的 operationId }); // 然后你可以使用这个 openApiDocument 对象 // 1. 通过 Swagger UI 或 Redoc 提供可视化文档 // 2. 导出为 JSON/YAML 文件供其他工具使用 import { writeFileSync } from 'fs'; writeFileSync('./openapi.json', JSON.stringify(openApiDocument, null, 2));

注意事项:OpenAPI 生成是“尽力而为”的。由于 ts-rest 支持纯 TypeScript 类型(c.type<T>()),这些类型在运行时无法被反射,因此可能无法完全转换为准确的 OpenAPI Schema。对于需要完整 OpenAPI 支持的情况,建议全部使用 Zod Schema 定义契约。

5. 常见问题与避坑指南

在实际项目中踩过一些坑后,我总结出以下经验,能帮你更顺畅地使用 ts-rest。

5.1 如何处理文件上传等非 JSON 请求?

ts-rest 默认假设请求和响应体是 JSON。对于文件上传,你需要使用contentType选项并调整处理方式。

const contract = c.router({ uploadAvatar: { method: 'POST', path: '/users/avatar', contentType: 'multipart/form-data', // 关键! body: c.type<FormData>(), // 使用 FormData 类型 responses: { 200: c.type<{ url: string }>() }, }, }); // 服务器端 (Express) const router = s.router(contract, { uploadAvatar: async ({ req }) => { // 使用原生 req 获取文件 const formData = await req.file(); // 使用如 `multer` 或 `busboy` 的中间件 // ... 处理文件 return { status: 200, body: { url: '...' } }; }, }); // 客户端 const formData = new FormData(); formData.append('file', file); await client.uploadAvatar({ body: formData });

注意:对于multipart/form-dataapplication/x-www-form-urlencoded,ts-rest 的类型系统可能无法像 JSON 那样提供深层的字段校验。此时,c.type<FormData>()c.type<URLSearchParams>()是更合适的选择,实际的字段验证需要在服务器端手动进行。

5.2 路径参数、查询参数和请求体的命名冲突

一个常见的困惑是:当路径参数、查询参数和请求体对象中有同名字段时,ts-rest 如何区分?答案是:它们是完全独立的命名空间

const contract = c.router({ updateItem: { method: 'PUT', path: '/items/:id', // `id` 来自路径 query: z.object({ version: z.string() }), // `version` 来自查询字符串 ?version=xxx body: z.object({ id: z.string(), name: z.string() }), // 这里的 `id` 是请求体的一部分 responses: { 200: ItemSchema }, }, });

在服务器处理函数中,你会收到一个聚合对象:

async ({ params, query, body }) => { // params.id 来自路径 `/items/123` // query.version 来自查询字符串 `?version=v2` // body.id 和 body.name 来自请求体 JSON console.log(params.id, query.version, body.id); }

避坑技巧:尽量避免在请求体中发送与路径参数相同的字段,除非有特殊理由,因为这可能引起混淆。如果必须如此,请确保在业务逻辑中明确区分它们的用途。

5.3 错误处理与响应类型细化

ts-rest 强制你声明可能的响应状态码,但如何处理这些错误是开发者的事。一个最佳实践是创建一个统一的错误处理 Hook 或工具函数。

// frontend/src/lib/api-error.ts import { ApiError } from '@ts-rest/core'; export class AppApiError extends Error { constructor( public statusCode: number, public body: unknown, message?: string ) { super(message || `API Error ${statusCode}`); } } // 在初始化客户端时包装 fetch const baseClient = initClient(contract, { baseUrl: '...', api: async (args) => { const response = await fetch(args); if (!response.ok) { // 尝试解析错误体 let errorBody; try { errorBody = await response.json(); } catch { errorBody = await response.text(); } // 抛出自定义错误,方便在 UI 中捕获和处理 throw new AppApiError(response.status, errorBody); } return response; }, }); // 在 React 组件中使用 const { data, error } = apiClient.getPost.useQuery(['post', id], { params: { id } }); if (error) { if (error instanceof AppApiError) { if (error.statusCode === 404) { return <div>文章未找到</div>; } else if (error.statusCode === 401) { redirectToLogin(); } } return <div>未知错误</div>; }

5.4 性能与包大小考量

@ts-rest/core本身非常轻量(约 3KB min+gzip)。它的类型魔法在编译时完成,运行时开销几乎为零。主要的体积增长来自你选择的适配器(如@ts-rest/express)和 Zod。

  • Tree-shaking:确保你的打包工具(如 Webpack、Vite、Rollup)支持 Tree-shaking。只导入你需要的模块。
  • Zod 优化:在大型契约中,Zod Schema 的实例化可能成为启动时的性能瓶颈。考虑在开发和生产环境使用不同的 Zod 配置(例如,在生产环境禁用详细的错误信息)。
  • 契约分割:不要将所有 API 定义在一个巨大的契约文件中。按功能模块分割,并结合代码分割(Code Splitting)技术,在前端按需加载客户端代码。

5.5 迁移策略:如何将现有 API 迁移到 ts-rest?

对于已有项目,全盘重写是不现实的。ts-rest 支持渐进式迁移:

  1. 从新功能开始:为下一个要开发的新 API 端点创建 ts-rest 契约。新旧 API 可以在服务器中共存。
  2. 包装现有路由:对于旧的 Express 路由,你可以创建一个 ts-rest 处理函数来“包装”它,逐步将类型安全引入。
    // 旧的 Express 路由 app.get('/api/old/posts', oldGetPostsHandler); // 新的 ts-rest 契约和包装器 const newContract = c.router({ getPosts: { ... } }); const newRouter = s.router(newContract, { getPosts: async ({ query }) => { // 调用旧的 handler,并确保返回格式符合新契约 const result = await callOldHandler(query); return { status: 200, body: result }; }, }); app.use(s.expressRouter(newRouter)); // 挂载到新路径,如 /api/v2/posts
  3. 并行运行与切换:让前端同时支持新旧 API 端点一段时间。逐步将前端调用切换到新的类型安全客户端,并废弃旧的 API。

在我经历过的几次迁移中,最大的收益并非来自新代码,而是在修改旧代码时,ts-rest 契约像一张安全网,立刻就能暴露出前后端不匹配的问题,极大地提升了重构的信心和效率。它可能不是所有场景的银弹,但对于追求开发体验和长期维护性的 TypeScript 全栈项目而言,ts-rest 提供的“魔法体验”绝对是物超所值的投资。

http://www.jsqmd.com/news/817899/

相关文章:

  • 医疗设备应急选择:大厂呼吸机与开源方案的可靠性、合规性与部署策略深度对比
  • 2026徐州市黄金回收白银回收铂金回收店铺哪家好 靠谱门店推荐及联系方式_转自TXT - 盛世金银回收
  • JavaScript进阶_01_映射的方法
  • 2026长垣市黄金回收白银回收铂金回收店铺哪家好 靠谱门店推荐及联系方式_转自TXT - 盛世金银回收
  • VSCode + TypeScript:一站式配置@路径智能提示与模块解析,告别‘Cannot find module’
  • 小红书禁止下载怎么办?2026年实测5大保存方法+最强工具评测 - 科技热点发布
  • 数据库分片实战:从理论到ShardingSphere落地
  • 1958-2024年乡镇的逐月土壤湿度数据
  • MSI-X中断机制深度解析:从硬件原理到Linux驱动实战与性能调优
  • 基于MCP协议构建AI与Docker的智能运维桥梁
  • 2026招远市黄金回收白银回收铂金回收店铺哪家好 靠谱门店推荐及联系方式_转自TXT - 盛世金银回收
  • 工业级OTP语音芯片在仿生驱鸟器中的选型与应用实践
  • 为Python数据分析脚本集成Taotoken实现智能文本摘要与分类
  • Claude 3 Opus vs GPT-4 Turbo vs Gemini 1.5 Pro(2024Q2真实负载压测实录)
  • Arduino与CircuitPython驱动3.5寸TFT触摸屏:SPI通信、图形显示与触摸交互全解析
  • Cadence新手避坑指南:用Padstack Editor搞定0402电阻和STM32的贴片焊盘(附命名规范)
  • Redis分布式锁进阶第五十一篇
  • 别再只用STM32了!手把手教你用STM32F4+FPGA EP2C8搭建低成本多轴运动控制器(附S形加减速算法避坑)
  • 2026十堰市黄金回收白银回收铂金回收店铺哪家好 靠谱门店推荐及联系方式_转自TXT - 盛世金银回收
  • 2026昭通市黄金回收白银回收铂金回收店铺哪家好 靠谱门店推荐及联系方式_转自TXT - 盛世金银回收
  • Unity放置经营模板深度分析:资源、建筑与离线收益如何实现?
  • LangGraph、OpenClaw、Hermes大比拼:Agent开发三路线,一次看懂!
  • 集合进阶(Collections Set List)
  • 2026沅江市黄金回收白银回收铂金回收店铺哪家好 靠谱门店推荐及联系方式_转自TXT - 盛世金银回收
  • LLM安全攻防实战:从提示注入到越狱攻击的防御体系构建
  • 虚拟机网络排查实战:宿主机和Ubuntu虚拟机桥接后互相ping不通?看这篇就够了
  • 新手入门,用外卖系统吃透Tomcat与Java Web全流程
  • 2026石家庄市黄金回收白银回收铂金回收店铺哪家好 靠谱门店推荐及联系方式_转自TXT - 盛世金银回收
  • NDS中文游戏资源汇总 中文游戏全集+NDS金手指+NDS模拟器
  • 医学图像自监督学习:MIRAM架构解决乳腺病变诊断难题