Skip to content

TypeScript 在工程中的实践

TypeScript 的价值不在"比 JavaScript 多一层类型注解",而在把运行期的错误提前到编译期、把隐式约定变成显式约束。本文聚焦工程落地:先立一套严格基线,再谈业务类型建模与泛型,最后收拢到 tsconfig / ESLint / CI / 声明发布等工程化实践。示例均以 TS ≥ 5.5 验证,个别标注版本。

工程角色的三条原则

  1. 类型是文档的机器可读版本:好的类型让人不看实现也能拼对 API;
  2. 类型是约束而非负担:大多数"写不出来"的泛型,是在提示你该收敛数据结构或收敛流程;
  3. 类型要能溯源到运行:无法映射到运行时行为的类型(过度抽象、伪造精确)最终会骗过自己和协作者。

配套一句俗语:"以 20% 的类型体操时间换取 80% 的可维护性收益,剩下的 80% 体操往往不值得。"

一、严格配置基线(起点,不是可选项)

工程化从 tsconfig 开始。生产项目建议直接以"严格全家桶"为起点:

jsonc
// 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
  }
}

坑 1skipLibCheck: true 会跳过 node_modules 里的 .d.ts 检查——这是速度的妥协而非安全的妥协,别把它关掉来"消除"第三方库的类型错误。

坑 2erasableSyntaxOnly 开启后 enumnamespaceconstructor 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 里性价比最高的模式:

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 只校验不拓宽:

ts
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 语义做进类型

ts
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

ts
// 不推荐: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

ts
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,这是类型体操的地基:

ts
// 提取 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 映射类型与键操作(基础体操四件套)

ts
// 只读
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 递归类型:拍平任意深结构

ts
// 任意深度的路径元组:'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)。

四、类型体操的工程化纪律

体操不是炫技,是要能被团队评审的代码。三条纪律:

  1. 先写意图,再写实现:复杂工具类型先用中文注释说明"输入→输出",再落代码;
  2. 每条工具类型都带断言用例(可跑 tsc 验证):
ts
// 用例(类型层面"断言")
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
  1. 能内置就用内置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 })只解包一次,禁止散落各处。
ts
// api/typegen/schema.d.ts(由 openapi-typescript 生成,勿手改)
export interface paths { '/products': { get: { responses: { 200: { content: { 'application/json': Product[] } } } } } }

5.2 运行时校验:zod 与 TS 互补

类型在编译期消失,边界数据(网络、localStorage、表单、env)必须在运行时校验

ts
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

jsonc
// 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)关键规则组:

ts
// 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'
    }
  }
)

坑 3any 是"类型感染的起点"。no-explicit-any 开启后第一个月团队会哀嚎,三个月后 unknown + 窄化会成为肌肉记忆。工具链推荐 tsx(跑 TS 脚本)/ tsc 做检查,ESLint 不检查类型错误——别拿 lint 当 typecheck

5.4 声明发布(库/共享包)

给 npm 包做类型发布的最短正确清单:

  • tsconfig.build.jsondeclaration: truedeclarationMap: trueemitDeclarationOnly: true
  • package.json 同时给 typesexportstypes 条件(双保险,现代解析用 exports);
  • 发版前跑 npm run build 后检查产物含 .d.ts;顺手用 api-extractor(可选)做 API 面审计;
  • 不要发布 src:消费者读的是 .d.ts,类型与实现脱节时立刻会被发现。

5.5 类型安全的三不管地带

场景建议
运行时未知 JSONunknown 起步 → 收窄 → 需要 shape 时 zod.parse
第三方无类型库新建 .d.ts 声明(declare module),不要用 any 逃逸
旧的 JS 模块逐步迁移checkJs + // @ts-check 逐文件开启,配 allowJs
复杂事件字典 / 消息总线interface MessageMap + keyof MessageMap 收口,见下
ts
// 事件总线示例:类型即 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))  // 参数被自动推导

六、常见坑位速查(踩过的都在这)

  1. any 泄漏:某个库函数返回 any,污染一整条调用链。对策:在边界立即 unknown 化并窄化,宁可多写一个收窄函数。
  2. 非空断言 ! 滥用foo!.bar 是"我比编译器懂",等价于手动 as。出现频次高说明数据流建模有洞(多半缺校验层)。
  3. interface vs type 乱用:原则——可扩展的公共 API 用 interface(声明合并),其余用 type(联合、映射、元组、交叉都只有 type 能做)。团队约定后别混。
  4. 函数参数用变体类型当参数:回调签名过宽/过窄都会报错,涉及参数逆变。工具方法(map/filter)回调若写不拢,检查是否用了 never 或把参数写成了只读/可选。
  5. as const 忘记加:配置对象被拓宽成 string,联合类型失效。数组转联合前记得 as const
  6. 依赖类型 "幽灵升级":第三方大版本悄悄改类型,构建没报、CI 也没跑 typecheck。让 CI 强制跑 tsc --noEmit 且锁版本(packageManager / lockfile + engines)。
  7. enum 的可达性问题:TS 官方文档自己都建议少用(数值 enum + 位运算除外)。erasableSyntaxOnly 开启后代码库会自然迁移到联合。
  8. 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 为准。

参考链接

基于 VitePress 构建 · 内容以知识共享方式沉淀