TypeScript 工程实践:从类型系统到项目架构的落地方法
TypeScript 的类型系统是它最强大的武器,但大多数项目只用了它不到一半的能力。本文从类型体操、错误处理、项目结构、配置策略四个层面,给出 TypeScript 项目从"能用"到"好维护"的工程实践——适合正在或准备用 TypeScript 构建中大型项目的团队。
先说结论:TypeScript 的优势不在”类型”,而在”可维护性”
很多团队用 TypeScript 只是因为”大家都在用”,但实际项目中 any 满天飞,类型定义和实际代码不一致,TypeScript 变成了”加了类型检查的 JavaScript”——该出的 bug 一个没少。
本文从四个层面展开:类型系统、错误处理、项目结构、配置策略。
1. 类型系统:用好 discriminated union 和类型收窄
1.1 用 discriminated union 替代可选字段
这是最常见的反模式:用一个对象类型,里面全是可选字段,运行时判断字段是否存在。
// ❌ 反模式
type ApiResponse = {
success?: boolean;
data?: any;
error?: string;
};
// ✅ 推荐:discriminated union
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string };
discriminated union 的好处是:TypeScript 会在类型收窄后自动推断出可用的字段,不需要手动判断。
const res: ApiResponse<User> = await fetchUser();
if (res.success) {
console.log(res.data.name); // ✅ TypeScript 知道 data 存在
} else {
console.log(res.error); // ✅ TypeScript 知道 error 存在
}
1.2 类型收窄的几种方式
// typeof 收窄(原始类型)
if (typeof x === 'string') { /* x: string */ }
// instanceof 收窄(类实例)
if (x instanceof Error) { /* x: Error */ }
// in 操作符收窄(对象属性)
if ('error' in x) { /* x: 有 error 属性的类型 */ }
// 自定义类型守卫
function isUser(x: any): x is User {
return x && typeof x.id === 'number' && typeof x.name === 'string';
}
2. 错误处理:用 Result 模式替代 throw
2.1 为什么不用 throw
throw 的问题在于:TypeScript 不强制调用方处理异常。一个函数 throws 了,但调用方可能忘了写 try-catch,程序会在运行时崩溃。
2.2 Result 模式实现
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseJSON(json: string): Result<unknown> {
try {
return { ok: true, value: JSON.parse(json) };
} catch (e) {
return { ok: false, error: e as Error };
}
}
// 调用方必须处理两种情况
const result = parseJSON('{"name":"test"}');
if (result.ok) {
console.log(result.value); // ✅ 类型安全
} else {
console.error(result.error.message); // ✅ 必须处理错误
}
2.3 顶层统一处理
在 API 路由或事件处理函数的顶层,将 Result 统一转换为响应:
app.get('/users', async (req, res) => {
const result = await getUsers();
if (!result.ok) {
return res.status(500).json({ error: result.error.message });
}
res.json(result.value);
});
3. 项目结构:分层清晰的类型定义
3.1 推荐目录结构
src/
types/ # 全局共享类型
user.ts
api.ts
common.ts
api/ # API 调用层
types.ts # 接口对应的类型(自动生成)
client.ts
utils/ # 工具函数
result.ts # Result 类型定义
components/ # 组件
UserCard.tsx
3.2 类型文件只放类型
不要在类型文件中写实现代码,哪怕只是一个简单的函数。类型文件和实现文件分离,职责清晰。
4. 配置策略
{
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
总结
| 层面 | 核心原则 | 常见错误 |
|---|---|---|
| 类型系统 | 用 discriminated union 替代可选字段 | any 满天飞 |
| 错误处理 | Result 模式替代 throw | 漏掉 try-catch |
| 项目结构 | 类型和实现分离 | 类型文件中写实现代码 |
| 配置 | strict: true | 逐项关闭严格检查 |
TypeScript 的价值不在于”写了类型”,而在于”类型帮你发现了你没想到的 bug”。 如果配置了 strict 模式、用了 discriminated union 和 Result 模式,TypeScript 的收益会远远大于它的学习成本。
需要 TypeScript 项目搭建或技术咨询?联系我们,说清你的项目规模与团队情况,24 小时内回可行性。
相关阅读
- 技术方案评审怎么做:选型、架构评审与可行性报告的方法 —— TypeScript 选型的技术决策方法
- 前端性能优化实战:从 Lighthouse 50 到 100 —— TypeScript 项目的性能优化实践
常见问题
any 类型到底能不能用?
能,但要有明确的使用场景和边界。建议:① 与第三方库交互时,如果对方没有类型定义,用 any 并限制在模块边界内,不扩散到业务代码;② 迁移 JavaScript 项目时,用 any 作为过渡,逐步替换为具体类型;③ 绝对不要在函数返回值上用 any——这会污染所有调用方的类型推断。推荐使用 unknown 替代 any,因为 unknown 在访问前必须做类型收窄,更安全。
tsconfig.json 里最关键的配置项是什么?
strict: true 是最重要的一行配置。它同时开启了 strictNullChecks、noImplicitAny、strictFunctionTypes 等多项检查,能拦截大量潜在 bug。除此之外,建议开启 noUnusedLocals 和 noUnusedParameters 来避免死代码,以及 exactOptionalPropertyTypes 来防止误写可选属性。
类型定义应该放在哪里?
推荐分层策略:① 全局共享类型放在 src/types/ 目录下,按模块分文件;② 组件或函数的局部类型就近定义,不放到全局类型文件中;③ 与 API 接口对应的类型放在 api/types.ts 中,从 OpenAPI spec 自动生成;④ 避免在类型文件中写实现代码,类型文件只放类型。
TypeScript 项目应该用什么错误处理模式?
推荐 Result 模式( discriminated union ),而不是 throw。定义一个 type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E },每个可能失败的函数都返回 Result 类型。调用方必须处理 ok 和 error 两种情况,不会漏掉错误处理。在顶层(如 API 路由处理函数)统一将 Result 转换为 HTTP 响应。