第 20 章

Node.js 后端:Fastify + TypeBox 路由级类型推导

第20章:Node.js 后端:Fastify + TypeBox 路由级类型推导

理解Node.js 后端是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用Fastify + TypeBox 路由级类型推导?关键的设计决策和陷阱是什么?

读完本章你将理解


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 的正确做法是引入 zodjoi 做运行时验证,再用类型断言把验证结果"告知"编译器。这个流程可行,但有模板代码,且两套系统(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.bodyreq.paramsreq.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 的 selectinclude 影响返回类型

// 不同查询,不同类型
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.bodyreq.paramsreq.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 的类型推导细节。

本章评分
4.8  / 5  (9 评分)

💬 留言讨论