说实话,我刚把TypeScript引入项目的时候,真的以为自己在写“更安全”的代码。结果第一周,我盯着满屏的红色波浪线怀疑人生——Object is possibly 'undefined'、Type 'X' is not assignable to type 'Y'、Could not find a declaration file for module...。那些报错像是一群没头苍蝇在代码里乱撞。
但现在的我,看着项目里几万个类型定义、零运行时类型错误,会心一笑。这条路我踩过所有的坑,今天想把它们全部摊开,让你少摔几次跟头。
别急着开严格模式:你的项目不是教科书
很多教程上来就甩给你一个完整的tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noEmitOnError": true
}
}
然后说“复制粘贴,享受类型安全”。听起来很美好,对吧?
我试过的后果是:项目直接起飞——起飞到报错的天上去。一个30万行代码的Node.js项目,开启strict: true后,编译失败,报错12000+条。你打算一个一个修吗?你还没改完,产品经理已经把新需求拍你脸上了。
我的建议是:渐进式开启,而不是一步到位。
第一步:基础搭建(第一周)
先只开启最基础、收益最高的配置:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
}
}
skipLibCheck: true这行极其重要。它跳过对node_modules中.d.ts文件的检查,避免因为第三方库类型定义不完善而一堆垃圾报错。我见过太多人卡在这一步,明明代码没问题,就是编译不过。
这一组配置,能让你的项目拥有基本的类型推导能力,同时又不会因为过于严格而瘫痪。
第二步:逐步收紧(第一个月)
当项目稳定运行、基础类型覆盖率达到80%左右时,可以考虑开启这些更严格的规则:
{
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": false,
"noUnusedParameters": false
}
}
注意noUnusedLocals和noUnusedParameters我暂时设为false。因为在重构过程中,你可能会删掉某些变量但暂时还不想改函数签名。设为true会立刻产生大量噪音,打击你的积极性。等项目稳定了,再开启也不迟。
第三步:极致严格(项目成熟期)
只有当你对自己的代码质量有足够信心,或者项目进入维护后期、需要长期稳定时,才考虑开启:
{
"compilerOptions": {
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true
}
}
这几个配置非常严格。比如noUncheckedIndexedAccess会要求你对所有索引访问都做空值检查。这意味着arr[0]不再是T,而是T | undefined。这很好,但代价是几乎所有数组访问都需要改代码。
一个真实案例:我有一个项目的API响应处理,原来写的是const name = response.data.user.name。开启这个配置后,它变成了const name = response.data?.user?.name,然后还要处理所有可能的undefined路径。改起来很痛苦,但改完之后,线上因为类型错误导致的Bug从每周3-4个降到了几乎为零。
中间件和数据库:两个最大的坑
Express/Koa中间件类型地狱
TypeScript和Express的结合,从一开始就不太友好。中间件类型定义一直是个老大难问题。
看这段代码,你可能觉得没问题:
app.use(async (req, res, next) => {
const user = await findUser(req.headers.userId);
if (!user) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.user = user; // 类型错误!
next();
});
报错:Property 'user' does not exist on type 'Request'。
为什么?因为Express的Request类型定义里没有user字段。你需要扩展它:
import { Request } from 'express';
interface CustomRequest extends Request {
user?: User;
}
app.use(async (req: CustomRequest, res, next) => {
const user = await findUser(req.headers.userId);
if (!user) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.user = user;
next();
});
这个模式在你的项目中重复了二十次,每次都写一遍CustomRequest,烦不烦?
我的解决方案是:全局扩展,统一管理。
在项目根目录创建一个types/express.d.ts:
import 'express';
declare module 'express' {
interface Request {
user?: User;
role?: 'admin' | 'user' | 'guest';
pagination?: {
page: number;
limit: number;
};
}
}
这样,你在任何中间件里都可以直接访问req.user,不需要每次都强转。但要注意,这个文件必须被TypeScript编译器识别到。确保你的tsconfig.json包含它:
{
"include": ["src/**/*", "types/**/*"]
}
MongoDB/Prisma的类型同步
数据库层面的类型同步,是另一个让人头疼的地方。
如果你用Mongoose,你需要手写Schema和Type:
// 定义Schema
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
age: Number,
createdAt: { type: Date, default: Date.now }
});
// 定义Type(手动维护,容易不同步)
interface IUser {
name: string;
email: string;
age?: number;
createdAt: Date;
}
// 创建Model
const User = mongoose.model<IUser>('User', userSchema);
问题在于,如果你改了Schema但没有改Type,或者反之,类型就不同步了。运行时可能出错,而TypeScript编译时毫无察觉。
我的做法是使用InferSchemaType和DocumentType,让TypeScript从Schema自动推导Type:
import mongoose, { Schema, Document } from 'mongoose';
interface IUser extends Document {
name: string;
email: string;
age?: number;
createdAt: Date;
isValid(): boolean;
}
const userSchema = new Schema<IUser>({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
age: Number,
createdAt: { type: Date, default: Date.now }
}, {
timestamps: true,
methods: {
isValid() {
return this.age && this.age > 0;
}
}
});
const User = mongoose.model<IUser>('User', userSchema);
这样,Type完全从Schema和接口定义推导,不会不同步。
如果你用Prisma,那就简单多了。prisma generate命令会根据你的schema.prisma自动生成类型定义:
// 你只需要导入生成的类型
import { PrismaClient, User } from '@prisma/client';
const prisma = new PrismaClient();
async function getUser(id: string): Promise<User | null> {
return prisma.user.findUnique({ where: { id } });
}
但这里有个坑:Prisma的类型在某些复杂查询下会失效。比如你用$queryRaw做原始查询,返回的类型会被推断为any。解决办法是手动指定返回类型:
interface RawUser {
id: string;
name: string;
email: string;
}
const users = await prisma.$queryRaw<RawUser[]>`
SELECT id, name, email FROM users WHERE age > ${25}
`;
异步错误处理:Promise.allSettled而不是Promise.all
Node.js项目里,异步操作是家常便饭。但很多开发者的错误处理姿势还是2015年的:
// 错误的姿势
const [user, posts, comments] = await Promise.all([
fetchUser(id),
fetchPosts(id),
fetchComments(id)
]);
如果fetchPosts失败了,整个Promise.all就reject了,user和comments即使拿到了也会被丢弃。而且你甚至不知道是哪个请求失败——除非你额外捕获错误信息。
正确的姿势是用Promise.allSettled:
const results = await Promise.allSettled([
fetchUser(id),
fetchPosts(id),
fetchComments(id)
]);
const userResult = results[0];
const postsResult = results[1];
const commentsResult = results[2];
if (userResult.status === 'fulfilled') {
const user = userResult.value;
// 处理用户数据
} else {
console.error('Failed to fetch user:', userResult.reason);
}
// 同理处理posts和comments...
这样,即使某个请求失败,其他请求的结果也不会被浪费。对于需要聚合多个数据源的场景,这非常有用。
但这里有个TypeScript的类型问题:Promise.allSettled返回的数组类型是(PromiseFulfilledResult<T> | PromiseRejectedResult)[]。你需要用in操作符来区分:
for (const result of results) {
if ('status' in result && result.status === 'fulfilled') {
console.log(result.value);
} else {
console.log(result.reason);
}
}
文件上传和流处理:类型安全与性能兼顾
处理文件上传是Node.js项目的常见需求,但这里有个陷阱:TypeScript的类型系统不知道流的存在。
看这段代码:
import multer from 'multer';
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
const file = req.file;
// 如果没上传文件,file是undefined
const path = file.path; // 类型错误!
res.json({ path: file.path });
});
TypeScript会报错:Object is possibly 'undefined'。因为你没有在中间件里处理file不存在的场景。
正确的写法是:
app.post('/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const path = req.file.path;
res.json({ path });
});
但这只是表面。更深层的问题是:如何处理大文件流而不阻塞事件循环?
很多开发者会这样做:
// 错误的姿势:把整个文件加载到内存
const buffer = await readFile(req.file.path);
const processed = processImage(buffer);
这在文件大时会直接OOM(内存溢出)。
正确的姿势是用流:
import { createReadStream, createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
import { Transform } from 'stream';
const processStream = new Transform({
transform(chunk, encoding, callback) {
// 处理每个chunk
const processed = processChunk(chunk);
callback(null, processed);
}
});
await pipeline(
createReadStream(req.file.path),
processStream,
createWriteStream('output.jpg')
);
但这里有个TypeScript的坑:pipeline的类型定义在某些版本下不够严格。你可能需要手动指定类型:
await pipeline(
createReadStream(req.file.path) as NodeJS.ReadableStream,
processStream as Transform,
createWriteStream('output.jpg') as NodeJS.WritableStream
);
as强转看起来很丑,但在这种底层API面前,有时是必要的。
配置文件的类型安全:别用JSON,用.ts
很多项目用.json文件存配置,比如config.json:
{
"database": {
"host": "localhost",
"port": 5432,
"name": "myapp"
},
"redis": {
"host": "localhost",
"port": 6379
}
}
然后用import config from './config.json'来读取。TypeScript会警告:Could not find a declaration file for module './config.json'。
你可能会加一个declare module '*.json'来消除这个警告,但这并没有真正解决类型安全的问题——你还是在运行时访问config.database.host,而TypeScript不知道这个路径是否存在。
更好的做法是用.ts文件导出配置:
// config.ts
export const database = {
host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT) || 5432,
name: process.env.DB_NAME || 'myapp'
} as const;
export const redis = {
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379
} as const;
export type Config = typeof config;
用as const让TypeScript推断出精确的类型,而不是string或number。这样,当你写database.port时,TypeScript知道它是5432,而不是number。
更进阶的做法是用zod做运行时验证:
import { z } from 'zod';
const configSchema = z.object({
database: z.object({
host: z.string().default('localhost'),
port: z.number().default(5432),
name: z.string().default('myapp')
}),
redis: z.object({
host: z.string().default('localhost'),
port: z.number().default(6379)
})
});
const config = configSchema.parse({
database: {
host: process.env.DB_HOST,
port: process.env.DB_PORT,
name: process.env.DB_NAME
},
redis: {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
}
});
这样,你在启动时就能验证配置是否合法,而不是等到运行时才报错。
测试:让测试代码也有类型安全
很多项目的测试代码没有用TypeScript,或者用了但类型松散。这会导致测试本身成为Bug的来源。
一个常见的错误写法:
// test/user.test.ts
describe('getUser', () => {
it('should return user by id', async () => {
const user = await getUser('123');
expect(user.name).toBe('John'); // 如果user是undefined,这里会崩
});
});
如果getUser返回undefined(比如用户不存在),测试会在运行时崩溃,而不是在编译时被发现。
正确的写法是:
describe('getUser', () => {
it('should return user by id', async () => {
const user = await getUser('123');
expect(user).not.toBeNull();
expect(user!.name).toBe('John'); // 断言后可以用!,因为已经排除了null
});
it('should return null for non-existent user', async () => {
const user = await getUser('999');
expect(user).toBeNull();
});
});
更进阶的做法是用expectTypeOf(来自vitest)来断言类型:
import { expectTypeOf } from 'vitest';
describe('getUser types', () => {
it('should return User | null', async () => {
const user = await getUser('123');
expectTypeOf(user).toMatchTypeOf<User | null>();
});
});
这样,如果getUser的返回类型变了,测试会在编译时失败,而不是在运行时。
日志系统:让日志也有类型
日志是调试和监控的基础,但很多项目的日志系统是字符串拼接,缺乏类型安全:
logger.info('User ' + userId + ' logged in from ' + ipAddress);
这种方式不仅难读,还容易出错。比如,你忘记加+,或者变量名拼错,TypeScript不会帮你检查。
更好的做法是用结构化日志,结合类型:
”`typescript import { pino } from ‘pino’;
const logger = pino({ base: {
serviceName: 'user-service',
environment: process.env.NODE_ENV
} });
// 定义日志上下文类型 interface UserLoginContext { userId: string; ipAddress: string; userAgent: string; timestamp: number; }
function logUserLogin(context: UserLoginContext) { logger.info({
event: 'user.login',
...context
}, ‘User logged in’); }
// 使用 logUserLogin({ userId: ‘123’, ipAddress: ‘192.168.1.
