嘿,说到把 TypeScript 搬进 Node.js 项目,很多人第一反应是:“又要写一堆类型,好麻烦啊。” 其实不然。我刚入行那会儿,写纯 JS 也是飞起,但等项目到了几十个接口、几百个模块的时候,那种“不知道这个变量到底是啥”、“改一个函数吓得不敢动”的感觉,真的让人头秃。TypeScript 就像是一个严厉但靠谱的 Code Reviewer,虽然一开始啰嗦,但能帮你避开绝大多数低级错误。
今天咱们不聊虚的,直接上手。我会带着你从最基础的 tsconfig 配置聊起,深入到怎么优雅地定义类型,再到写一个真正能用的中间件,最后看看那些让人抓狂的类型错误该怎么排查。咱们目标是让代码既安全又好维护,顺便还能教教刚入行的小朋友,为什么“类型”这么重要。
第一步:把 TypeScript 请进门——基础配置
首先,你得有个 Node.js 项目。假设你已经 npm init -y 过了。接下来,安装 TypeScript 和 Node 相关的类型定义(这一步很多人会漏,导致 require 或者 process.env 没提示):
npm install typescript @types/node --save-dev
然后,生成配置文件:
npx tsc --init
这时候,你会得到一个 tsconfig.json。别急着改,先看看里面默认有哪些选项。对于 Node.js 后端项目,我建议你重点调整这几个核心配置,它们决定了 TypeScript 怎么“理解”你的代码:
{
"compilerOptions": {
"target": "ES2020", // 编译出的 JS 目标版本,Node.js 14+ 支持 ES2020
"module": "CommonJS", // Node.js 默认模块系统
"strict": true, // 开启所有严格类型检查,这是代码质量的基石
"esModuleInterop": true, // 允许 import 默认导出,解决兼容性问题
"skipLibCheck": true, // 跳过 node_modules 里的类型检查,加快编译速度
"forceConsistentCasingInFiles": true, // 强制文件名大小写一致,避免跨平台问题
"resolveJsonModule": true, // 允许导入 JSON 文件
"outDir": "./dist", // 编译输出目录
"rootDir": "./src", // 源码根目录
"declaration": true, // 生成 .d.ts 声明文件,方便其他项目引用
"sourceMap": true // 生成 source map,方便调试
},
"include": ["src/**/*"], // 只编译 src 目录下的文件
"exclude": ["node_modules", "dist"]
}
这里有个小细节: 很多新手会犹豫要不要开 strict: true。我的建议是,除非你有一个 legacy 的大存量代码库要迁移,否则一定要开。因为 strict 包含了 noImplicitAny、strictNullChecks 等关键检查,它能逼着你处理那些“可能是空”的情况。比如,你获取数据库返回的数据,strictNullChecks 会提醒你:“嘿,这个值可能是 null,你得处理一下!”这能避免生产环境炸雷。
另外,为了开发体验,我们通常还会安装 ts-node 来直接运行 TypeScript 代码,不用每次都先编译:
npm install ts-node -D
然后在 package.json 里加个脚本:
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
这样,你改完代码直接 npm run dev,TypeScript 会实时编译并运行,爽得很。
第二步:给代码穿上防护服——类型定义的艺术
类型定义是 TypeScript 的核心。在 Node.js 项目中,我们最常打交道的是接口(Interface)和类型别名(Type Alias)。别把它们混用,各有各的用处。
1. 接口 vs 类型别名
想象你在写一个用户管理系统。
// 用 interface 定义用户结构,适合描述对象形状
interface User {
id: number;
username: string;
email: string;
role: 'admin' | 'user'; // 联合类型,限制只能是这两个字符串之一
}
// 用 type 定义复杂类型,适合联合、交叉或函数签名
type UserId = string | number; // 用户 ID 可以是字符串或数字
type Callback<T> = (data: T) => void; // 泛型回调函数
为什么这么分? 接口可以被 extend(继承),也可以被 merge(合并),适合描述可扩展的对象模型。而 type 更灵活,可以表示联合类型、元组、映射类型等。在 Node.js 后端,我通常用 interface 定义数据模型(如数据库实体),用 type 定义工具类型或参数组合。
2. 泛型:让函数和类更通用
你肯定写过这样的代码:
function handleUser(user: User) { ... }
function handleAdmin(admin: Admin) { ... }
如果用户和管理员有很多共同字段,这不累吗?用泛型!
interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
interface User extends BaseEntity {
username: string;
email: string;
}
interface Admin extends BaseEntity {
permissions: string[];
}
// 泛型函数,T 可以是任何实现了 BaseEntity 的类型
async function fetchEntity<T extends BaseEntity>(id: string): Promise<T> {
// 模拟从数据库查询
const data = await db.query<T>(`SELECT * FROM ${getTableName<T>()} WHERE id = ?`, [id]);
return data[0];
}
这里 T extends BaseEntity 约束了 T 必须包含 id, createdAt, updatedAt 字段。这样,fetchEntity<User> 和 fetchEntity<Admin> 都能被正确推断,而且编译器会检查你返回的数据是否符合 T 的形状。
给小朋友的例子: 泛型就像是一个“万能盒子”。你可以把玩具车放进盒子里,也可以把积木放进盒子里。盒子本身不知道里面装的是什么,但它保证不管装什么,你都能从盒子里拿出来玩。这样你就不需要为每种玩具准备一个特定的盒子了。
3. 处理外部库的类型
Node.js 项目离不开各种库。有些库自带类型(如 express、mongoose),有些则没有。对于没有类型的库,你可以用 @types 包来补充,或者用 declare module 来声明类型。
比如,你引入一个没有类型的模块 my-utils:
// types/my-utils.d.ts
declare module 'my-utils' {
export function formatDate(date: Date): string;
export function generateId(): string;
}
这样,TypeScript 就能识别 my-utils 的导出函数了。
第三步:中间件开发——TypeScript 的战场
中间件是 Node.js 服务器(尤其是 Express/Koa)的灵魂。用 TypeScript 写中间件,最大的好处是能清楚地定义输入和输出,避免“黑盒”调用。
1. Express 中间件的类型定义
Express 的中间件类型其实有点复杂,因为 req 和 res 是可以被扩展的。我们来写一个验证 Token 的中间件。
首先,定义扩展的 Request 类型:
// types/express.d.ts
import { Request } from 'express';
declare global {
namespace Express {
interface Request {
userId?: string; // 解析后的用户 ID
userRole?: 'admin' | 'user'; // 用户角色
}
}
}
然后,写中间件:
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET || 'your-secret-key';
// 明确指定中间件类型,让编辑器知道 req 被扩展了
export const authMiddleware = (
req: Request,
res: Response,
next: NextFunction
): void => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Access denied. No token provided.' });
return;
}
try {
const decoded = jwt.verify(token, SECRET) as { id: string; role: string };
req.userId = decoded.id; // 赋值到扩展的 req 上
req.userRole = decoded.role as 'admin' | 'user'; // 类型断言,确保角色合法
next();
} catch (error) {
res.status(403).json({ error: 'Invalid token.' });
}
};
关键点: 注意 req.userId 和 req.userRole 是可选的(?),因为如果中间件失败,它们可能不会被赋值。但在通过了中间件的路由处理器里,你应该用类型守卫来确保它们存在。
2. 错误处理中间件
错误处理中间件有特殊的签名,必须包含四个参数:err, req, res, next。
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction
): void => {
console.error(err.stack); // 记录错误日志
// 根据错误类型返回不同的响应
if (err.name === 'JsonWebTokenError') {
res.status(401).json({ error: 'Invalid token.' });
} else if (err.name === 'TokenExpiredError') {
res.status(401).json({ error: 'Token expired.' });
} else {
res.status(500).json({ error: 'Internal server error.' });
}
};
这里用 err.name 来判断错误类型,而不是用 instanceof,因为 instanceof 在不同模块加载时可能不可靠。同时,Error 类型提供了标准的 name 和 stack 属性,这样你就不需要自定义错误类也能处理常见错误。
给小朋友的例子: 中间件就像是学校里的安检门。每个学生(请求)都要经过它。安检员(中间件)检查你的学生证(Token)。如果证件没问题,你就进去上课(next());如果证件有问题,保安就会拦住你(返回错误响应)。TypeScript 规定了安检员必须有什么工具(函数签名),以及他能往学生证上盖什么章(扩展 req 对象)。
3. 泛型中间件
有时候,你需要一个通用的验证中间件,比如验证请求体中的某个字段。
import { Request, Response, NextFunction } from 'express';
// 泛型中间件,V 是验证器的类型
export function validateBody<T>(validator: (body: any) => T) {
return (req: Request, res: Response, next: NextFunction) => {
try {
const validatedData = validator(req.body);
req.validatedBody = validatedData; // 假设你扩展了 Request
next();
} catch (error) {
res.status(400).json({ error: 'Invalid body' });
}
};
}
// 使用示例
const userValidator = (body: any) => {
if (!body.username || typeof body.username !== 'string') {
throw new Error('Username is required');
}
if (!body.email || !body.email.includes('@')) {
throw new Error('Valid email is required');
}
return { username: body.username, email: body.email } as { username: string; email: string };
};
app.post('/users', validateBody(userValidator), (req, res) => {
// req.validatedBody 已经是 { username: string; email: string } 类型了
res.json({ message: 'User created', data: req.validatedBody });
});
这样,validateBody 可以复用于任何需要验证请求体的场景,而且类型安全。
第四步:常见错误排查——别让类型系统把你难倒
即使是最熟练的 TypeScript 开发者,也会遇到类型错误。以下是一些常见的问题和解决方法。
1. “Property does not exist on type ‘object’”
这是新手最常遇到的错误。当你从 API 获取数据时,TypeScript 不知道数据的结构。
const response = await fetch('https://api.example.com/user');
const data = await response.json();
console.log(data.username); // Error: Property 'username' does not exist on type 'object'
解决: 定义接口,并类型断言。
interface UserData {
username: string;
email: string;
}
const data = await response.json() as UserData;
console.log(data.username); // OK
注意: 类型断言只是告诉编译器“我相信这个类型是对的”,如果 API 返回的数据结构变了,运行时还是会报错。所以,在生产环境中,最好结合运行时验证库(如 zod 或 joi)来确保数据形状。
2. “Type ‘string | undefined’ is not assignable to type ‘string’”
strictNullChecks 开启后,任何可能为 undefined 的值都不能直接赋给非空类型。
const username: string = req.query.username; // Error!
解决: 使用可选链或默认值。
const username: string = req.query.username ?? 'default'; // 使用空值合并运算符
// 或者
if (req.query.username) {
const username: string = req.query.username;
}
3. “Argument of type ‘X’ is not assignable to parameter of type ‘Y’”
这通常发生在函数调用时,参数类型不匹配。
function greet(name: string) {
console.log(`Hello, ${name}`);
}
greet(undefined); // Error!
解决: 检查传入的参数是否可能为 null 或 undefined,并确保类型一致。如果参数可以是可选的,修改函数签名:
function greet(name?: string) {
console.log(`Hello, ${name || 'stranger'}`);
}
4. 循环依赖导致的类型错误
在大型 Node.js 项目中,模块之间的循环依赖会导致 TypeScript 无法正确推断类型。
// a.ts
import { b } from './b';
export const a = b + 1;
// b.ts
import { a } from './a';
export const b = a + 1;
解决: 重构代码,打破循环依赖。或者,使用动态导入(import())来延迟加载。
// a.ts
export const a = async () => {
const { b } = await import('./b');
return b + 1;
};
5. 第三方库的类型问题
有些库的类型定义不完整或有错误。这时候,你可以用 @ts-ignore 或 @ts-expect-error 来临时绕过,但这只是权宜之计。更好的做法是贡献类型定义到 DefinitelyTyped 仓库,或者自己编写 .d.ts 文件。
// 临时忽略错误
// @ts-ignore
const result = someLibrary.unknownFunction();
// 或者,明确说明你期望的错误
// @ts-expect-error 这个函数在未来版本会废弃
someLibrary.deprecatedFunction();
给小朋友的例子: 类型错误就像是你把红色积木放进蓝色积木的插槽里。编译器会说:“嘿,这不对!” 你需要找到正确的积木(正确的类型),或者换一种方式连接(类型断言或转换)。有时候,积木本身设计有问题(第三方库类型错误),你就得自己修一修(编写 .d.ts 文件)。
第五步:实战技巧——提升代码质量和维护效率
1. 使用 Zod 进行运行时验证
TypeScript 的类型检查只在编译时有效。如果 API 返回的数据不符合预期,运行时还是会报错。这时,zod 这样的库就能派上用场。
import { z } from 'zod';
// 定义用户 Schema
const UserSchema = z.object({
id: z.number(),
username: z.string().min(3),
email: z.string().email(),
role: z.enum(['admin', 'user']),
});
// 解析数据
const parseUser = (data: unknown) => {
try {
return UserSchema.parse(data);
} catch (error) {
console.error('Invalid user data:', error);
return null;
}
};
// 使用
const userInput = { id: 1, username: 'ab', email: 'not-an-email', role: 'superadmin' };
const user = parseUser(userInput);
if (user) {
console.log(user.username); // TypeScript 知道 user 是 z.infer<typeof UserSchema> 类型
}
zod 还能自动生成 TypeScript 类型,你不需要手动写 interface。
2. 模块联邦和类型共享
如果你的 Node.js 项目拆分成多个微服务,共享类型定义非常重要。你可以创建一个 @myorg/types 包,发布到内部 npm 仓库,其他服务都依赖它。
// @myorg/types
export interface User {
id: string;
username: string;
}
export interface Post {
id: string;
title: string;
authorId: string;
}
这样,所有服务都使用同一套类型定义,避免了类型不一致的问题。
3. 文档生成
TypeScript 的注释可以被文档生成工具(如 typedoc)解析,自动生成 API 文档。
”`typescript /**
- 获取用户信息
- @param id
