Node.js 后端:Fastify + TypeBox 路由级类型推导
第20章:Node.js 后端:Fastify + TypeBox 路由级类型推导
理解Node.js 后端是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用Fastify + TypeBox 路由级类型推导?关键的设计决策和陷阱是什么?
读完本章你将理解:
- Express 的类型困境
- Fastify + TypeBox:Schema 即类型
- 路由级完整类型推导
Level 1 · 你需要知道的(1-3年经验)
Express 的类型困境
Express 是 Node.js 最流行的框架,但它的类型设计有一个根本性缺陷:请求体是 any。
// Express 路由 — 类型系统形同虚设
app.post('/users', (req, res) => {
const name = req.body.name; // 类型:any
const age = req.body.age; // 类型:any
const typo = req.body.naem; // 类型:any — 拼写错误,编译器不报错
res.json({ id: '123' });
});
req.body 默认是 any,意味着你可以访问任何不存在的属性,编译器保持沉默。即便手动添加泛型,也只是装饰:
// Express 的泛型方案:手动声明,容易过时
interface CreateUserBody {
name: string;
age: number;
}
app.post<{}, {}, CreateUserBody>('/users', (req, res) => {
const name = req.body.name; // 现在是 string — 但这只是你的断言,不是推导
// Express 不会验证运行时数据是否真的符合这个类型
// 如果客户端发来 { name: 123 },TypeScript 不会拦截
});
核心问题:类型和验证逻辑分离。类型在编译时存在,验证在运行时发生,两者各自为政,任何一个出错都不影响另一个。
Express 的正确做法是引入 zod 或 joi 做运行时验证,再用类型断言把验证结果"告知"编译器。这个流程可行,但有模板代码,且两套系统(schema + type)需要同步维护。
Fastify + TypeBox:Schema 即类型
Fastify 从设计之初就把 JSON Schema 作为路由的第一等公民。TypeBox 是一个库,它让你用 TypeScript 写出同时满足 JSON Schema 规范和 TypeScript 类型的对象——一份定义,两种用途。
安装
npm install fastify @fastify/type-provider-typebox @sinclair/typebox
TypeBox 基础:构建 Schema
import { Type, Static } from '@sinclair/typebox';
// 基础类型
const NameSchema = Type.String({ minLength: 1, maxLength: 100 });
const AgeSchema = Type.Number({ minimum: 0, maximum: 150 });
// 对象 Schema
const CreateUserBodySchema = Type.Object({
name: Type.String({ minLength: 1 }),
age: Type.Number({ minimum: 0 }),
email: Type.Optional(Type.String({ format: 'email' })),
});
// 从 Schema 提取 TypeScript 类型
type CreateUserBody = Static<typeof CreateUserBodySchema>;
// 等价于:{ name: string; age: number; email?: string }
// TypeBox 生成的 JSON Schema,Fastify 用它做运行时验证
console.log(JSON.stringify(CreateUserBodySchema));
// {"type":"object","properties":{"name":{"type":"string","minLength":1},...},"required":["name","age"]}
Type.Object() 返回一个对象,它既是有效的 JSON Schema(可以传给任何 JSON Schema 验证器),也携带 TypeScript 类型信息(通过 Static<> 提取)。
TypeBox 常用构建块
import { Type } from '@sinclair/typebox';
// 原始类型
Type.String() // string
Type.Number() // number
Type.Boolean() // boolean
Type.Null() // null
Type.Literal('admin') // 'admin'
// 修饰符
Type.Optional(Type.String()) // string | undefined(在对象属性中)
Type.Readonly(Type.String()) // readonly string
// 复合类型
Type.Array(Type.String()) // string[]
Type.Tuple([Type.String(), Type.Number()]) // [string, number]
Type.Union([Type.String(), Type.Number()]) // string | number
Type.Intersect([SchemaA, SchemaB]) // A & B
// 对象
Type.Object({ id: Type.String(), count: Type.Number() })
// 枚举
Type.Enum({ Active: 'active', Inactive: 'inactive' })
// 带约束的字符串
Type.String({ minLength: 1, maxLength: 255, pattern: '^[a-z]+$' })
// 带范围的数字
Type.Number({ minimum: 0, maximum: 100, multipleOf: 1 })
路由级完整类型推导
import Fastify from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';
const fastify = Fastify().withTypeProvider<TypeBoxTypeProvider>();
// 定义路由 Schema
const CreateUserSchema = {
body: Type.Object({
name: Type.String({ minLength: 1 }),
age: Type.Number({ minimum: 0 }),
role: Type.Optional(Type.Union([
Type.Literal('admin'),
Type.Literal('user'),
])),
}),
response: {
201: Type.Object({
id: Type.String(),
name: Type.String(),
createdAt: Type.String(),
}),
400: Type.Object({
error: Type.String(),
message: Type.String(),
}),
},
} as const;
fastify.post('/users', { schema: CreateUserSchema }, async (req, reply) => {
// req.body 类型被完全推导:
// { name: string; age: number; role?: 'admin' | 'user' }
const { name, age, role } = req.body;
// name 是 string — 编译器知道
const upperName = name.toUpperCase();
// role 是 'admin' | 'user' | undefined — 联合类型缩窄正确工作
if (role === 'admin') {
// 这里 role 被缩窄为 'admin'
}
// reply.code(201).send() 的参数类型与 response[201] Schema 匹配
return reply.code(201).send({
id: crypto.randomUUID(),
name,
createdAt: new Date().toISOString(),
});
});
关键点:.withTypeProvider<TypeBoxTypeProvider>() 告诉 Fastify 使用 TypeBox 作为类型提供者。之后,每条路由的 schema 字段都会自动推导 req.body、req.params、req.query 的类型。
路由参数和查询字符串
const GetUserSchema = {
params: Type.Object({
id: Type.String({ pattern: '^[0-9a-f-]{36}$' }),
}),
querystring: Type.Object({
include: Type.Optional(Type.Array(Type.String())),
format: Type.Optional(Type.Union([
Type.Literal('json'),
Type.Literal('csv'),
])),
}),
response: {
200: Type.Object({
id: Type.String(),
name: Type.String(),
age: Type.Number(),
}),
404: Type.Object({ error: Type.String() }),
},
};
fastify.get('/users/:id', { schema: GetUserSchema }, async (req, reply) => {
const { id } = req.params; // string
const { include, format } = req.query; // string[] | undefined, 'json' | 'csv' | undefined
const user = await db.users.findById(id);
if (!user) {
return reply.code(404).send({ error: 'User not found' });
}
return reply.send(user);
});
Level 2 · 它是怎么运行的(3-5年经验)
类型化中间件:扩展 Request/Reply
Fastify 插件系统允许你在 request 对象上附加数据。通过声明合并,类型也能同步扩展。
// auth-plugin.ts
import { FastifyPluginAsync, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
// 扩展 Fastify 的类型声明
declare module 'fastify' {
interface FastifyRequest {
user: {
id: string;
role: 'admin' | 'user';
email: string;
} | null;
}
}
const authPlugin: FastifyPluginAsync = async (fastify) => {
// 在每个请求前运行
fastify.addHook('preHandler', async (request, reply) => {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
request.user = null;
return;
}
try {
request.user = await verifyToken(token);
} catch {
reply.code(401).send({ error: 'Invalid token' });
}
});
};
export default fp(authPlugin);
// 路由中使用
fastify.get('/profile', {
preHandler: [requireAuth],
schema: { response: { 200: UserProfileSchema } }
}, async (req, reply) => {
// req.user 类型:{ id: string; role: 'admin' | 'user'; email: string } | null
if (!req.user) {
return reply.code(401).send({ error: 'Unauthorized' });
}
// 现在 req.user 缩窄为非 null
const profile = await db.profiles.findByUserId(req.user.id);
return reply.send(profile);
});
requireAuth 前置处理器
// 利用类型守卫缩窄类型
async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
if (!request.user) {
return reply.code(401).send({ error: 'Authentication required' });
}
}
类型化错误处理
Fastify 的错误处理钩子也可以完整类型化:
import { FastifyError } from 'fastify';
// 自定义错误类
class AppError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
// 全局错误处理器
fastify.setErrorHandler((error: FastifyError | AppError, request, reply) => {
if (error instanceof AppError) {
return reply.code(error.statusCode).send({
code: error.code,
message: error.message,
});
}
// Fastify 的验证错误
if (error.validation) {
return reply.code(400).send({
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details: error.validation,
});
}
// 未预期错误
fastify.log.error(error);
return reply.code(500).send({
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
});
});
注意:Fastify 在 Schema 验证失败时会自动抛出错误,格式为 Fastify 的标准验证错误,你不需要自己在每条路由里检查类型。
Prisma 集成:类型安全的数据库查询
Prisma 为每个 Model 自动生成类型,与 Fastify + TypeBox 的组合尤为契合。
// schema.prisma
model User {
id String @id @default(uuid())
name String
age Int
email String? @unique
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
}
enum Role {
ADMIN
USER
}
import { PrismaClient } from '@prisma/client';
import { Type, Static } from '@sinclair/typebox';
const prisma = new PrismaClient();
// TypeBox Schema 与 Prisma 类型协同工作
const CreateUserBodySchema = Type.Object({
name: Type.String({ minLength: 1 }),
age: Type.Number({ minimum: 0, maximum: 150 }),
email: Type.Optional(Type.String({ format: 'email' })),
});
type CreateUserBody = Static<typeof CreateUserBodySchema>;
// 路由处理器:body 类型与 Prisma create 参数对齐
fastify.post('/users', { schema: { body: CreateUserBodySchema, response: { 201: UserResponseSchema } } }, async (req, reply) => {
const { name, age, email } = req.body;
// prisma.user.create 的参数类型由 Prisma 推导
// 如果传入 Prisma schema 不存在的字段,编译器报错
const user = await prisma.user.create({
data: { name, age, email },
select: {
id: true,
name: true,
createdAt: true,
},
});
// user 类型:{ id: string; name: string; createdAt: Date }
// select 改变了返回类型 — TypeScript 自动推导
return reply.code(201).send({
...user,
createdAt: user.createdAt.toISOString(),
});
});
Prisma 的 select 和 include 影响返回类型
// 不同查询,不同类型
const userBasic = await prisma.user.findUnique({
where: { id },
select: { id: true, name: true },
});
// 类型:{ id: string; name: string } | null
const userWithPosts = await prisma.user.findUnique({
where: { id },
include: { posts: true },
});
// 类型:(User & { posts: Post[] }) | null
// 用 Prisma.UserGetPayload 提取复杂查询的类型
import { Prisma } from '@prisma/client';
type UserWithPosts = Prisma.UserGetPayload<{
include: { posts: true };
}>;
// 等价于 User & { posts: Post[] }
完整 CRUD 示例
import Fastify from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';
import { PrismaClient } from '@prisma/client';
const fastify = Fastify({ logger: true }).withTypeProvider<TypeBoxTypeProvider>();
const prisma = new PrismaClient();
// 共享 Schema
const UserIdParam = Type.Object({ id: Type.String() });
const UserBody = Type.Object({
name: Type.String({ minLength: 1 }),
age: Type.Number({ minimum: 0 }),
email: Type.Optional(Type.String({ format: 'email' })),
});
const UserResponse = Type.Object({
id: Type.String(),
name: Type.String(),
age: Type.Number(),
email: Type.Union([Type.String(), Type.Null()]),
createdAt: Type.String(),
});
// GET /users
fastify.get('/users', {
schema: { response: { 200: Type.Array(UserResponse) } }
}, async (req, reply) => {
const users = await prisma.user.findMany();
return users.map(u => ({ ...u, createdAt: u.createdAt.toISOString() }));
});
// POST /users
fastify.post('/users', {
schema: { body: UserBody, response: { 201: UserResponse } }
}, async (req, reply) => {
const user = await prisma.user.create({ data: req.body });
return reply.code(201).send({ ...user, createdAt: user.createdAt.toISOString() });
});
// GET /users/:id
fastify.get('/users/:id', {
schema: { params: UserIdParam, response: { 200: UserResponse, 404: Type.Object({ error: Type.String() }) } }
}, async (req, reply) => {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) return reply.code(404).send({ error: 'Not found' });
return { ...user, createdAt: user.createdAt.toISOString() };
});
// PATCH /users/:id
fastify.patch('/users/:id', {
schema: {
params: UserIdParam,
body: Type.Partial(UserBody), // 所有字段变为可选
response: { 200: UserResponse },
}
}, async (req, reply) => {
const user = await prisma.user.update({
where: { id: req.params.id },
data: req.body,
});
return { ...user, createdAt: user.createdAt.toISOString() };
});
// DELETE /users/:id
fastify.delete('/users/:id', {
schema: { params: UserIdParam, response: { 204: Type.Null() } }
}, async (req, reply) => {
await prisma.user.delete({ where: { id: req.params.id } });
return reply.code(204).send();
});
Level 3 · 规范怎么定义的(资深)
Fastify 的类型系统设计与 Express 的根本区别在于:Fastify 将 JSON Schema 作为路由的第一等公民。.withTypeProvider<TypeBoxTypeProvider>() 在类型层面激活了 TypeBox 的类型推导管道——之后每条路由的 schema 字段中的 TypeBox schema 会自动推导出 req.body、req.params、req.query 的精确类型。TypeBox 的 Type.Object() 返回的对象同时满足 JSON Schema 规范和 TypeScript 类型系统——它通过 Static<typeof Schema> 提取编译时类型,同时序列化为标准 JSON Schema 供 Fastify 做运行时验证。
Level 4 · 边界与陷阱(所有人)
反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
(req.body as CreateUserBody) |
绕过验证,类型是谎言 | 用 TypeBox Schema + TypeBoxTypeProvider |
| 在路由外手动验证再断言 | 验证逻辑重复,与 Schema 脱节 | 让 Fastify + TypeBox 做运行时验证 |
Type.Any() 或 Type.Unknown() 覆盖整个 body |
放弃类型安全 | 具体定义每个字段 |
忘记 .withTypeProvider<TypeBoxTypeProvider>() |
类型推导失效,body 仍是 unknown | 初始化 Fastify 时立即添加 |
| Prisma 查询结果直接序列化(Date 对象) | Date 无法被 JSON.stringify 正确序列化 |
转换为 .toISOString() 字符串 |
总结
| 能力 | Express | Fastify + TypeBox |
|---|---|---|
req.body 类型 |
any(默认) |
从 Schema 自动推导 |
| 运行时验证 | 手动(需引入 zod/joi) | 内置(JSON Schema) |
| Schema 与类型同步 | 需要手动维护两套 | 一份定义,自动同步 |
| 响应类型检查 | 无 | reply.send() 参数类型检查 |
| 参数/查询字符串 | any |
从 Schema 推导 |
| 性能 | 基准 | 比 Express 快 2–10 倍(Fastify 设计目标) |
下一章讨论前端:React 组件如何正确类型化——为什么不推荐 React.FC,泛型组件怎么写,以及 Hooks 的类型推导细节。