嘿,说到把 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