TypeScript 在工程中的实践
TypeScript 的价值不在"比 JavaScript 多一层类型注解",而在把运行期的错误提前到编译期、把隐式约定变成显式约束。本文聚焦工程落地:先立一套严格基线,再谈业务类型建模与泛型,最后收拢到 tsconfig / ESLint / CI / 声明发布等工程化实践。示例均以 TS ≥ 5.5 验证,个别标注版本。
工程角色的三条原则
- 类型是文档的机器可读版本:好的类型让人不看实现也能拼对 API;
- 类型是约束而非负担:大多数"写不出来"的泛型,是在提示你该收敛数据结构或收敛流程;
- 类型要能溯源到运行:无法映射到运行时行为的类型(过度抽象、伪造精确)最终会骗过自己和协作者。
配套一句俗语:"以 20% 的类型体操时间换取 80% 的可维护性收益,剩下的 80% 体操往往不值得。"
一、严格配置基线(起点,不是可选项)
工程化从 tsconfig 开始。生产项目建议直接以"严格全家桶"为起点:
// tsconfig.base.json(团队内可共享)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler", // 现代打包器下的解析规则(TS ≥ 5.0)
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
// 三个最容易被忽略的严格项:
"noUncheckedIndexedAccess": true, // obj[i] 可能是 undefined
"exactOptionalPropertyTypes": true, // 可选属性不接受显式 undefined
"noImplicitOverride": true, // 覆写方法必须写 override
// 类型擦除友好 / 发布友好:
"erasableSyntaxOnly": true, // 禁止 enum / namespace 等运行时残留语法(TS ≥ 5.8)
"verbatimModuleSyntax": true, // import type 必须显式,与打包器一致
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true, // 只检查自己写的代码
"noEmit": true,
"allowImportingTsExtensions": true
}
}坑 1:
skipLibCheck: true会跳过node_modules里的.d.ts检查——这是速度的妥协而非安全的妥协,别把它关掉来"消除"第三方库的类型错误。坑 2:
erasableSyntaxOnly开启后enum、namespace、constructor parameter properties会直接报错。团队早期大量使用enum的话,先给过渡期,同时用const enum/联合类型逐步替代(见下一节)。
分层结构(monorepo 或应用均适用)
tsconfig.base.json # 共享编译基线
tsconfig.app.json # 源码层(引用 base,开启 noEmit 检查用)
tsconfig.node.json # 构建脚本 / 配置文件(Node 侧)应用用 tsc --noEmit -p tsconfig.app.json 做纯类型检查,构建由 Vite / esbuild / swc 负责转译,职责分离、互不干扰。
二、业务类型建模(比体操更值钱)
2.1 联合类型与判别式(discriminated union)
建模"同一形态有多种变体"时,判别联合 + switch 穷尽是 TS 里性价比最高的模式:
type ApiState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Product[]; fetchedAt: number }
| { status: 'error'; error: Error }
declare function render(state: ApiState): string {
switch (state.status) {
case 'idle': return '等待'
case 'loading': return '加载中'
case 'success': return `共 ${state.data.length} 件(${state.fetchedAt})`
case 'error': return state.error.message
}
}增删变体时编译器会提示每个 switch 缺失的分支——类型系统帮你维护状态机。
2.2 用 satisfies 保持字面量精确
as 断言会破坏结构(值类型被"拍扁"成接口),satisfies 只校验不拓宽:
type RouteMap = Record<string, { path: string; title: string; auth?: boolean }>
const routes = {
home: { path: '/', title: '首页' }, // 缺 auth,OK(可选)
admin: { path: '/admin', title: '后台', auth: true } // auth: true 而不是 boolean
} satisfies RouteMap
// routes.admin.auth 推断为 boolean 吗?不——是字面量 true 吗?都不对:
// satisfies 让 routes 保持精确推断(auth: true),同时结构受 RouteMap 约束区别记牢:
as RouteMap= 我断言它是什么(编译期失明);satisfies RouteMap= 你检查它是什么(编译期保留精度)。
2.3 Branded type:把单位与 ID 语义做进类型
declare const brand: unique symbol
type UserId = string & { [brand]: 'UserId' }
type OrderId = string & { [brand]: 'OrderId' }
function toUserId(raw: string): UserId { return raw as UserId }
declare function getUser(id: UserId): User
declare function getOrder(id: OrderId): Order
// getUser(getOrderId()) ❌ 编译错误:OrderId 不能赋给 UserId对"同为 string 但语义完全不同"的 ID / 货币金额等做隔离,防的是函数签名层面的低级错位。
2.4 不要用类 enum,用联合 + as const
// 不推荐:enum 会生成运行时对象,且在模块边界有命名冲突历史包袱
// 推荐:
export const HTTP_METHOD = ['GET', 'POST', 'PUT', 'DELETE'] as const
export type HttpMethod = (typeof HTTP_METHOD)[number] // 'GET' | 'POST' | 'PUT' | 'DELETE'
// 想枚举 + 展示文案时:
export const SORT_OPTIONS = {
newest: '最新',
hottest: '最热',
recommended: '推荐'
} as const
export type SortKey = keyof typeof SORT_OPTIONS // 'newest' | 'hottest' | ...联合类型可被判别式、模板字面量自由组合,enum 不行;且 erasableSyntaxOnly 时代这是更干净的方案。
三、泛型进阶(控制类型流)
3.1 泛型默认值 + 约束 + NoInfer
function createStore<T extends Record<string, unknown>, K extends string = 'default'>(
name: K,
initial: T
): { name: K; state: T } {
return { name, state: initial }
}
// createStore('cart', { items: [] }) → name: 'cart',字面量保留
// NoInfer:阻止调用端推断污染约束端(TS ≥ 5.4)
function bind<T, U extends NoInfer<T>>(a: T, b: U): [T, U] {
return [a, b]
}
bind('x', 'y') // T='string',U 不被 'y' 反向拓宽
// bind('x', 42) // ❌ U 不是 T 的子类型NoInfer 用于"参数只该来自另一参数、不参与正向推断"的场景,常见于校验、键提取类 API 设计。
3.2 条件类型与 infer
"从形态中抓取结构"靠 infer,这是类型体操的地基:
// 提取 Promise 的载荷
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T
// 提取函数返回值
type Return<T> = T extends (...args: never[]) => infer R ? R : never
// 从"包一层"的类型里解包(React 组件 props 常见)
type ComponentProps<T> = T extends (props: infer P) => unknown ? P : never关键纪律:条件类型要给出穷尽分支(否则默认走
never/false 分支,排查困难);复杂条件类型务必配几组type X = ...的静态验证用例(见 §4)。
3.3 映射类型与键操作(基础体操四件套)
// 只读
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
// 可选化(浅层)
type PartialBy<T, K extends keyof T> = Omit<T, K> & { [P in K]?: T[P] }
// 挑出函数属性
type Methods<T> = {
[K in keyof T as T[K] extends (...args: never[]) => unknown ? K : never]: T[K]
}
// 模板字面量:给每个键加事件前缀("组件事件字典"常用)
type PrefixKeys<T extends string, P extends string> = { [K in T as `${P}${K}`]: () => void }3.4 递归类型:拍平任意深结构
// 任意深度的路径元组:'user' | 'user.profile' | 'user.profile.name'
type Path<T, Prefix extends string = ''> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? Path<T[K], `${Prefix}${K}.`> | `${Prefix}${K}`
: `${Prefix}${K}`
}[keyof T & string]
: never工程提醒:递归映射类型对深度有限的领域对象很香;对
JSON.parse回来的任意嵌套结构不要硬上,给个深度上限或交给运行时库(见 §5.5)。
四、类型体操的工程化纪律
体操不是炫技,是要能被团队评审的代码。三条纪律:
- 先写意图,再写实现:复杂工具类型先用中文注释说明"输入→输出",再落代码;
- 每条工具类型都带断言用例(可跑
tsc验证):
// 用例(类型层面"断言")
type _A = Expect<Equal<Awaited<Promise<string>>, string>> // 通过
type _B = Expect<Equal<Awaited<Promise<Promise<number>>>, number>>
type _C = Expect<Equal<Methods<{ a(): void; b: 1 }>, { a(): void }>>
// 配套两个"断言原语"(写在类型工具区即可)
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
? true : false
type Expect<T extends true> = T- 能内置就用内置:
Partial/Required/Pick/Omit/Record/Exclude/Extract/NonNullable/Awaited/Capitalize是语言自带的,重造轮子前先搜一遍。
一个实用"真经":超过 30 行的工具类型如果只在代码里用一次,问问自己——是不是该把数据形状调简单一点?很多复杂的
infer其实是在掩盖后端/协议设计的不一致。
五、工程化落地(把类型接入流程)
5.1 API 类型单一来源:从 Schema 或协议文件生成
不要手写 interface UserDTO 与后端各存一份:
- 后端 OpenAPI →
openapi-typescript生成全量客户端 DTO; - 前后端同仓 → 共享
dto包(npm workspace / pnpm workspace); - 手写时至少约定:DTO 一律位于独立文件,响应包装层(
{ code, data, msg })只解包一次,禁止散落各处。
// api/typegen/schema.d.ts(由 openapi-typescript 生成,勿手改)
export interface paths { '/products': { get: { responses: { 200: { content: { 'application/json': Product[] } } } } } }5.2 运行时校验:zod 与 TS 互补
类型在编译期消失,边界数据(网络、localStorage、表单、env)必须在运行时校验:
import { z } from 'zod'
export const EnvSchema = z.object({
VITE_API_BASE: z.string().url().default('/api'),
VITE_ENABLE_MOCK: z.enum(['true', 'false']).default('false')
})
export type Env = z.infer<typeof EnvSchema>
// 在入口处 parse 一次,之后全项目拿到的是"已被证实的类型"
const env = EnvSchema.parse(import.meta.env)选型视角:zod(生态最大)/ valibot(体积小)/ arktype(性能与 DX)。原则是 schema 单点定义,类型从 schema infer,杜绝"类型一份、校验一份"。
5.3 lint 与检查进 CI
// package.json
{
"scripts": {
"typecheck": "tsc --noEmit -p tsconfig.app.json",
"lint": "eslint . --max-warnings 0",
"check": "npm run typecheck && npm run lint"
}
}配合 typescript-eslint(v8+ 用新的 flat config)关键规则组:
// eslint.config.js(示意)
import tseslint from 'typescript-eslint'
export default tseslint.config(
tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-explicit-any': ['error', { fixToUnknown: true }],
'@typescript-eslint/no-unnecessary-condition': 'error', // 依赖类型收窄,抓"永远假"的分支
'@typescript-eslint/consistent-type-imports': 'error'
}
}
)坑 3:
any是"类型感染的起点"。no-explicit-any开启后第一个月团队会哀嚎,三个月后unknown+ 窄化会成为肌肉记忆。工具链推荐tsx(跑 TS 脚本)/tsc做检查,ESLint 不检查类型错误——别拿 lint 当 typecheck。
5.4 声明发布(库/共享包)
给 npm 包做类型发布的最短正确清单:
tsconfig.build.json:declaration: true、declarationMap: true、emitDeclarationOnly: true;package.json同时给types与exports的types条件(双保险,现代解析用exports);- 发版前跑
npm run build后检查产物含.d.ts;顺手用api-extractor(可选)做 API 面审计; - 不要发布
src:消费者读的是.d.ts,类型与实现脱节时立刻会被发现。
5.5 类型安全的三不管地带
| 场景 | 建议 |
|---|---|
| 运行时未知 JSON | unknown 起步 → 收窄 → 需要 shape 时 zod.parse |
| 第三方无类型库 | 新建 .d.ts 声明(declare module),不要用 any 逃逸 |
| 旧的 JS 模块逐步迁移 | checkJs + // @ts-check 逐文件开启,配 allowJs |
| 复杂事件字典 / 消息总线 | interface MessageMap + keyof MessageMap 收口,见下 |
// 事件总线示例:类型即 API 文档
type EventMap = {
'auth:login': { userId: string }
'auth:logout': void
'cart:update': { items: number }
}
type Listener<K extends keyof EventMap> = (payload: EventMap[K]) => void
declare function on<K extends keyof EventMap>(event: K, fn: Listener<K>): void
on('auth:login', ({ userId }) => console.log(userId)) // 参数被自动推导六、常见坑位速查(踩过的都在这)
any泄漏:某个库函数返回any,污染一整条调用链。对策:在边界立即unknown化并窄化,宁可多写一个收窄函数。- 非空断言
!滥用:foo!.bar是"我比编译器懂",等价于手动as。出现频次高说明数据流建模有洞(多半缺校验层)。 interfacevstype乱用:原则——可扩展的公共 API 用interface(声明合并),其余用type(联合、映射、元组、交叉都只有type能做)。团队约定后别混。- 函数参数用变体类型当参数:回调签名过宽/过窄都会报错,涉及参数逆变。工具方法(
map/filter)回调若写不拢,检查是否用了never或把参数写成了只读/可选。 as const忘记加:配置对象被拓宽成string,联合类型失效。数组转联合前记得as const。- 依赖类型 "幽灵升级":第三方大版本悄悄改类型,构建没报、CI 也没跑
typecheck。让 CI 强制跑tsc --noEmit且锁版本(packageManager/ lockfile +engines)。 enum的可达性问题:TS 官方文档自己都建议少用(数值 enum + 位运算除外)。erasableSyntaxOnly开启后代码库会自然迁移到联合。strict为 false 的老项目:不要一把梭开strict——先开noImplicitAny修完,再逐项开。配合@ts-expect-error记录"待修点",CI 对ts-expect-error数量做上限。
七、2026 年工具链备注
- 编译器路线:官方在做 TypeScript 原生移植(Go 实现,社区称
tsgo/"typescript-go"),目标是 10x 提速且语义兼容现有代码;2025 起开源预览。日常开发若用tsc检查慢,可关注此路线,但不要为了速度牺牲strict。 - 模块解析:新项目一律
moduleResolution: "bundler"+verbatimModuleSyntax,告别import type的模糊地带。 - 版本建议:本文示例在 TS 5.5–5.8 验证;
erasableSyntaxOnly(5.8+)、NoInfer(5.4+)、const类型参数(5.0+)按需使用。具体行为以各版本 Release Notes 为准。
参考链接
- TypeScript Handbook:Creating Types from Types、Generics
- TypeScript 5.x 发布说明(版本特性权威来源)
- typescript-eslint 文档:Rules(含 type-checked 规则)
- openapi-typescript:OpenAPI → TS 类型
- zod / valibot / arktype 文档(运行时校验三选一)
- tsconfig 参考:TSConfig Reference