TypeScript 最佳实践
核心原则
在 TypeScript 6.0 时代,类型系统的价值不应停留在“把 JavaScript 写得更像静态语言”,而应体现在降低错误率、压缩重构成本、强化跨层契约。真正成熟的 TypeScript 实践,关注的是设计质量,而不是注解数量。
稳定、可扩展的团队通常遵循以下原则:
- 先建模领域,再书写实现。 类型应该表达业务约束,而不是临时迎合界面或传输层结构。
- 把不确定性收敛在边界。 HTTP 请求、数据库结果、消息队列、文件系统、第三方 SDK 返回值,都应先视为
unknown。 - 让推断做局部工作,让显式类型守住边界。 不必为每个局部变量写类型,但对公共 API、跨模块接口、关键抽象要明确声明。
- 为可维护性设计,而不是为炫技设计。 高级类型能力只有在减少重复、提升正确性时才值得使用。
- 让编译期契约与运行时契约一致。 TypeScript 只负责静态分析,不会替你验证线上输入。
安装与版本管理
应将最新稳定版 TypeScript 6.0.x 作为项目级开发依赖,而不是依赖全局安装版本。
npm install -D typescript@~6.0.0
npx tsc --version建议的版本策略:
- 在生产项目中使用
~6.0.0这类 patch 级可控升级 范围。 - 将 TypeScript 升级与框架升级拆开处理,便于定位回归。
- 保证本地 CLI、编辑器语言服务、CI 使用同一主版本。
- 在 monorepo 中尽量统一 TypeScript 版本。
- 对共享库或平台仓库,记录编译器升级及兼容性影响。
如果你维护的是库而不是应用,还应验证声明文件与消费者运行环境的兼容性。
tsconfig 策略
tsconfig 不是样板文件,而是工程约束的一部分。成熟项目通常会分层管理:
tsconfig.base.json:共享基础配置tsconfig.json:编辑器体验、项目引用tsconfig.build.json:生产构建/声明输出tsconfig.test.json:测试环境覆盖项
Node.js ESM 项目的推荐基线:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"importsNotUsedAsValues": "error",
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}实践建议:
- 对库、平台层、基础设施代码,优先保持
skipLibCheck: false。 - 大型项目和 monorepo 应优先考虑 project references。
- 如果构建由 bundler 负责,
tsc --noEmit应作为独立的权威类型检查步骤。 - Node、Browser、Worker、Test 的全局类型环境应隔离管理,避免互相污染。
严格模式策略
"strict": true 只是起点,不是终点。大量线上缺陷都可以通过额外的严格选项提前暴露。
建议优先开启:
noUncheckedIndexedAccess:防止误以为数组或字典索引一定存在。exactOptionalPropertyTypes:区分“字段缺失”和“字段存在但值为undefined”。noImplicitOverride:在继承场景中防止重构误伤。useUnknownInCatchVariables:避免默认把异常当成Error使用。noFallthroughCasesInSwitch:减少状态分支遗漏。noPropertyAccessFromIndexSignature:处理不可信键值对象时很有价值。
如果某个严格选项引入大量告警,不要第一时间全局关闭。先判断它揭示的是“噪声”还是“真实风险”。
类型设计
优秀的类型设计首先关注语义边界与概念分层。
更推荐的做法:
- 使用具备业务语义的别名,例如
UserId、OrderStatus、IsoTimestamp - 使用可判别联合来描述状态机和结果流
- 默认偏向
readonly和不可变数据 - 明确建模
null/undefined,而不是把“可选”滥用到所有地方
应避免:
- 一个超大接口同时承担 API、数据库、缓存、UI 四层职责
- 为了“类型炫技”把业务规则全塞进复杂条件类型
- 大量
Record<string, unknown>占位但从不收敛成真实模型
示例:把创建输入、持久化记录、展示摘要拆开。
type UserId = string & { readonly brand: unique symbol };
type CreateUserInput = {
email: string;
displayName: string;
};
type UserRecord = {
id: UserId;
email: string;
displayName: string;
createdAt: string;
deletedAt: string | null;
};
type UserSummary = Pick<UserRecord, "id" | "displayName">;unknown 与 any
在生产代码里,any 应被视为受控逃逸口,而不是默认选项。面对不确定输入时,通常优先使用 unknown。
any会关闭类型检查,并污染后续推断。unknown要求你在使用前先收窄。never适合表达不可能状态,并帮助穷尽性检查。
function parseJson(text: string): unknown {
return JSON.parse(text);
}
function isFeatureFlagMap(value: unknown): value is Record<string, boolean> {
return typeof value === "object" && value !== null &&
Object.values(value).every((item) => typeof item === "boolean");
}如果必须与遗留插件或低质量第三方类型定义集成,应把 any 封装在单独适配层内,而不是扩散到业务代码中。
联合类型与交叉类型
联合类型最适合表达“多种可能之一”,交叉类型更适合表达“能力组合”。在生产系统中,可判别联合往往是描述工作流状态的最佳工具。
type PaymentResult =
| { kind: "authorized"; authorizationId: string }
| { kind: "declined"; reason: string }
| { kind: "failed"; retryable: boolean };
function handlePayment(result: PaymentResult): string {
switch (result.kind) {
case "authorized":
return result.authorizationId;
case "declined":
return result.reason;
case "failed":
return result.retryable ? "retry" : "stop";
default: {
const exhaustive: never = result;
return exhaustive;
}
}
}交叉类型适合组合正交能力,例如时间戳、审计信息、分页元数据;不适合拿来“强行拼接”互相并不兼容的对象结构。
泛型
泛型的核心价值是保留输入与输出之间的关系。如果一个泛型没有表达真实关系,它大概率就没有存在必要。
适合使用泛型的场景:
- 集合处理工具
- 仓储/网关抽象
- 通用异步结果包装
- 基于 schema 的复用工具
type Page<T> = {
items: T[];
nextCursor: string | null;
};
async function fetchPage<TDto>(url: string): Promise<Page<TDto>> {
const response = await fetch(url);
return response.json() as Promise<Page<TDto>>;
}建议:
- 当单字母泛型会降低可读性时,改用
TItem、TError、TContext这类具名参数。 - 当实现依赖某些属性时,显式加约束。
- 尽量让调用方依赖推断,而不是频繁手工传类型参数。
- 对业务代码中的深层条件泛型保持警惕,除非它确实显著减少重复。
satisfies
satisfies 的价值在于:既检查对象是否符合目标类型,又尽量保留字面量精度。
type RouteConfig = {
path: `/${string}`;
cache: "public" | "private";
timeoutMs: number;
};
const routes = {
health: { path: "/health", cache: "private", timeoutMs: 1_000 },
docs: { path: "/docs", cache: "public", timeoutMs: 5_000 }
} satisfies Record<string, RouteConfig>;特别适合用于:
- 配置对象
- 国际化字典
- feature flag 清单
- 路由表
- 需要保持字面量信息的测试数据
只要你想获得“校验 + 精确推断”,就应优先考虑 satisfies,而不是宽泛的 as 断言。
模板字面量类型
模板字面量类型非常强大,但在工程实践里应优先解决实际问题,而不是追求类型层面的“语言解析器”。
高价值场景:
- 路由模式
- 事件名
- 埋点键名
- 环境变量命名约定
- 设计系统 token 标识
type Entity = "user" | "invoice";
type Action = "created" | "deleted";
type DomainEvent = `${Entity}.${Action}`;如果字符串协议本身解析复杂,优先编写运行时校验,再把校验结果映射到更窄的类型。
实用工具类型
内置工具类型是 TypeScript 的基础设施,但不能把它们当作“省事捷径”滥用。
典型用途:
Pick/Omit:派生聚焦视图Partial:表达 patch 语义,而不是“我现在还没想好结构”Readonly:为不可变 API 提供约束Record:表达已知键空间Exclude/Extract:收窄联合类型ReturnType、Parameters、Awaited:连接现有函数与异步层
type User = {
id: string;
email: string;
displayName: string;
role: "admin" | "member";
};
type UserPatch = Partial<Pick<User, "displayName" | "role">>;
type PublicUser = Omit<User, "email">;如果工具类型链条已经影响阅读,应及时命名中间概念。
模块边界
成熟项目里,大多数 TypeScript 问题发生在模块边界,而不是纯函数内部。
建议:
- 默认只导出公共领域类型和稳定接口,不随意暴露内部辅助类型。
- 一个文件只承担一个清晰职责。
- 避免跨特性目录的深层导入。
- 对纯类型依赖使用
import type,保持语义清晰,也让 ESM 输出更干净。 - 在共享库中优先使用命名导出,便于重构与检索。
健康的边界流程通常是:
- 外部数据以
unknown进入 - 通过校验得到稳定 DTO
- 再通过映射转成领域对象
- 业务逻辑仅消费领域类型
运行时校验
TypeScript 不会校验线上输入。任何跨越信任边界的数据,都必须在运行时解析或验证。
常见信任边界:
- HTTP 请求/响应
- 数据库记录
- 消息队列消息
- 环境变量
- CMS 内容
- 本地存储 / Cookie
- 第三方 SDK 回调
可根据技术栈选择 Zod、Valibot、ArkType 或 JSON Schema 方案。
import { z } from "zod";
const UserDtoSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
displayName: z.string().min(1)
});
type UserDto = z.infer<typeof UserDtoSchema>;
function parseUserDto(value: unknown): UserDto {
return UserDtoSchema.parse(value);
}只有静态类型、没有运行时校验,会带来虚假的安全感;只有运行时校验、没有类型输出,则会制造重复劳动。生产系统需要两者配合。
API 与客户端 DTO
不要用一个类型贯穿分布式系统的所有层。DTO 存在的根本原因,是传输层约束与领域层约束并不相同。
复杂度足够时,至少拆分以下概念:
- 请求 DTO
- 响应 DTO
- 领域实体
- 持久化记录
- UI 视图模型
建议:
- 把 API 返回值先视为“待校验 DTO”。
- DTO 到领域对象之间做显式映射。
- 对公共 API 契约进行有计划的版本管理。
- 不要把数据库字段名直接泄露到前端类型。
- 如果接口需要区分“字段未传”和“字段为空”,优先使用
null与明确语义,而不是混乱的可选字段。
这种分层会略微增加类型数量,但能显著降低迁移与演进时的耦合成本。
NodeNext 与 ESM 指南
对于现代 Node.js 项目,除非有明确历史包袱,建议优先采用:
module: "NodeNext"moduleResolution: "NodeNext"
关键实践:
- 既然选择原生 ESM,就尽量保持一致,不要半 ESM 半 CommonJS。
- 当运行时要求时,为相对导入写出明确扩展名。
- 使用
verbatimModuleSyntax: true降低输出行为的意外性。 - 合理使用
import type。 - 理解
package.json中type、exports、imports、types的边界作用。 - 不要在同一包内随意混用两套模块语义。
如果你维护的是库:
- 先明确是只发布 ESM,还是发布双格式
- 验证发布后的包结构,而不只是源代码能编译
- 确保声明文件与真实入口一致
对应用而言,模块系统越简单,长期维护成本越低。
构建与类型检查工作流
除非你的产物完全依赖 tsc 输出,否则应把转译/打包与类型检查视为两个独立环节。
常见生产方案:
- 用
tsup、esbuild、swc、Vite 或框架编译器负责快速构建 - 用
tsc --noEmit负责权威类型检查 - 需要声明文件或编译器输出时,再用
tsc -p tsconfig.build.json
示例脚本:
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsup src/index.ts --format esm --dts",
"build:types": "tsc -p tsconfig.build.json"
}
}CI 建议顺序:
- 使用锁文件安装依赖
- 运行 lint
- 运行 typecheck
- 运行 tests
- 运行生产构建
“能构建通过”并不等于“类型设计健康”。
测试
TypeScript 项目的测试,应同时覆盖运行时行为与类型契约。
建议分层:
- 单元测试:验证纯逻辑
- 集成测试:验证 I/O 边界与适配层
- 契约测试:验证 API 约定
- 类型测试:验证公共类型 API 与高级类型工具
推荐实践:
- 当模块解析或发布形态重要时,在接近真实运行路径下执行测试。
- 对共享库使用
tsd、expect-type或 Vitest 的类型断言能力。 - 避免过度 mock;只 mock 你不想真正执行的边界。
- 用
satisfies约束 fixture,让测试数据既精确又易维护。
Lint 与格式化
Lint 的职责是发现语义问题,格式化工具的职责是消除风格争论。
推荐组合:
- ESLint
typescript-eslint- Prettier
- 按需增加
eslint-plugin-import、eslint-plugin-unicorn或框架规则
高价值规则通常包括:
- 限制不安全的
any - 检查未处理的 Promise
- 阻止被误用的异步函数
- 统一 type-only imports
- 捕获多余断言和冗余条件
格式化应尽量自动化,不要把 Prettier 已经解决的问题再通过大量 lint 规则重复编码。
性能
大型项目中,TypeScript 的性能直接影响编辑器反馈速度,而反馈速度又会影响工程质量。
优化方向:
- 在 monorepo 中使用 project references
- 避免巨大联合类型爆炸和深层递归条件类型
- 复杂类型应命名并复用,而不是反复即时展开
- 减少内部包不必要的
.d.ts暴露面 - 先定位慢文件/慢类型,再做针对性优化
- 必要时升级类型性能较差的依赖
一个实用经验:如果某个类型需要一整段话才能解释清楚,它往往也不适合被长期维护。
迁移策略
为既有代码库升级 TypeScript 实践,关键不在“一次性重写”,而在于有顺序地降低风险。
建议迁移路径:
- 固定现代编译器版本
- 收敛并统一
tsconfig - 开启
strict - 优先修复 API、数据库、配置加载等边界类型
- 用收窄和校验替代宽泛断言
- 逐步开启
noUncheckedIndexedAccess等高价值严格选项 - 把过大的共享类型拆成分层模型
- 为
any/as增加 lint 约束与可见性
对于超大仓库,按目录、包、业务域分批推进,通常比大爆炸式改造更稳妥。
反模式
生产环境中常见的 TypeScript 反模式包括:
- 把
as当成设计工具,而不是最后的受控逃逸 - 一个“实体类型”同时服务后端、前端、存储、测试
- 在包入口导出内部实现细节类型
- 明明对象 + 函数更合适,却默认使用类
- 明明字符串字面量联合更灵活,却滥用
enum - 用泛型工具隐藏意图,而不是澄清意图
- 不加审查地把生成类型直接当成领域模型
- 永久开启
skipLibCheck来掩盖依赖质量问题 - 对
response.json() as SomeType盲目信任而不做校验
边界处求显式,核心处求简洁,通常是更稳的方向。
团队检查清单
一个成熟的 TypeScript 团队,通常应对以下问题大多回答“是”:
- 是否已固定当前稳定的 6.0.x 版本?
- 是否全仓开启了
strict? - 是否有意识地评估了额外严格选项,而不是停留在默认值?
- 是否把外部输入先视为
unknown再校验? - 是否在必要时区分 DTO、领域模型、持久化模型?
- 是否能清楚看到并解释
any与as的使用? - 是否明确了模块边界,并一致使用
import type与命名导出? - CI 是否分别执行 lint、typecheck、tests、build?
- 公共 API 是否有类型测试或契约测试?
- 迁移债务是否被跟踪,而不是被默认接受?