编程语言

TypeScript 类型设计实战:从 any 到可维护的类型

any 会在项目里悄悄扩散成一片类型黑洞。本文从判别联合、泛型约束到收窄失败的排查路径,给出一套能真正落地的类型建模方法:让编译器替你守住业务不变量,而不是靠注释和 code review。

作者:巧匠团队·9 分钟阅读·更新于 2026-09-30

any 是一个会传染的洞

`any` 的语义不是「我不知道」,而是「关掉类型检查」。一旦某个函数返回 `any`,整条调用链上的推导都会退化:下游变量变成 `any`,传参不再报错,重命名不再跳转,重构变成纯手工。真正的代价不是编译慢,而是你失去了唯一那份能自动发现空指针和字段拼写错误的机制。

如果只是为了绕过第三方库缺类型声明,正确的做法是造一个窄接口把 `any` 关在边界上,而不是让它进入业务层。一旦进入,删除它需要的成本通常是指数级的——因为没人能列全所有受影响的调用点。

真实项目里常见的顺序是:先有一个 `any`,接着出现 `any[]` 和 `Promise<any>`,最后整条数据流失去保护。判断标准很简单:如果把 `any` 替换成 `unknown` 会立刻爆出一百个错误,说明这条链路早就没有类型保障了。

// 反例:把 unknown 丢进业务层
function parse(input: string): any {
  return JSON.parse(input)
}

// 正例:边界处收窄,内部保持类型安全
type Config = { port: number; host: string }

function parseConfig(input: string): Config {
  const raw: unknown = JSON.parse(input)
  if (typeof raw !== "object" || raw === null) throw new TypeError("config must be an object")
  const port = (raw as Record<string, unknown>).port
  const host = (raw as Record<string, unknown>).host
  if (typeof port !== "number" || typeof host !== "string") {
    throw new TypeError("config.port must be number and config.host must be string")
  }
  return { port, host }
}

判别联合:让状态机成为类型

当一个对象有多种形态(加载中、成功、失败)时,用可选字段表示会带来无穷组合:`loading` 和 `error` 同时为真该怎么办?判别联合给每个分支一个字面量 tag,编译器就能检查分支是否穷尽、字段是否在错误的分支里被访问。

判别联合最大的收益是 `switch` 的穷尽性检查。只要你把联合类型的所有 tag 都列出来却漏了一个分支,`never` 断言会立刻报错。这意味着新增一种状态时,编译器会列出所有需要跟着改的 switch,而不是让你在运行期才发现漏处理。

注意 `never` 断言的位置要放在 switch 的 default 分支,并且用 `const _exhaustive: never = state` 这样的形式,而不是在每个 case 里写。否则一旦某个 case 的类型被收窄为空对象,你就失去了检查。

type RequestState =
  | { status: "idle" }
  | { status: "loading"; startedAt: number }
  | { status: "success"; data: string }
  | { status: "error"; message: string }

function render(state: RequestState): string {
  switch (state.status) {
    case "idle":
      return "尚未开始"
    case "loading":
      return `已耗时 ${Date.now() - state.startedAt}ms`
    case "success":
      return state.data
    case "error":
      return `失败:${state.message}`
    default: {
      const _exhaustive: never = state
      return _exhaustive
    }
  }
}

// 新增 { status: "cancelled" } 后,所有遗漏分支的 switch 都会编译报错

泛型约束:让 API 说明它真正的要求

不带约束的 `<T>` 等于 `<T>` 加一个隐藏的 any:函数里对 T 调用任何属性都不报错,调用方却可能传入原始类型。写 `T extends Record<string, unknown>` 只是起点,真正的收益来自把约束和返回值绑定,让 T 的能力穿过返回值继续传播。

实践中常用的三种模式是:约束 + 默认值(给调用方一个可用的起点)、约束 + 索引签名(允许任意键但值类型受控)、以及条件类型做输入到输出的映射(如 `ReturnType`、框架的 `MaybePromise<T>`)。第三种最容易被滥用,因为它容易把编译时间拖垮。

约束的价值在于「早失败」。一个约束良好的泛型函数在编译期就能拒绝错误的调用方,让错误停在边界;一个没有约束的泛型函数则把错误推迟到运行期,甚至推迟到生产环境才暴露。

type Mapper<T extends Record<string, unknown>> = {
  keys: () => Array<keyof T>
  get<K extends keyof T>(key: K): T[K] | undefined
  set<K extends keyof T>(key: K, value: T[K]): Mapper<T>
}

function createMapper<T extends Record<string, unknown> = { id: string }>(initial: T): Mapper<T> {
  const store = new Map<keyof T, T[keyof T]>(Object.entries(initial) as Array<[keyof T, T[keyof T]]>)
  return {
    keys: () => Array.from(store.keys()),
    get: (key) => store.get(key),
    set: (key, value) => {
      store.set(key, value)
      return createMapper(Object.fromEntries(store) as T)
    },
  }
}

// 传入 number 会立刻报错:Mapper<number> 不满足 Record<string, unknown>

收窄为什么失败:五类常见断点

第一种是对可选属性用 `if (obj.key)` 却忘了 `undefined` 分支;第二种是对数组用 `typeof` 或直接判空,而 TypeScript 只对少数类型做自动收窄;第三种最隐蔽:赋值给已声明为联合类型的变量时,TS 不会跨语句保留分支信息,需要显式断言或重新判断。

第四种是 `const` 与 `let` 的差异。如果你用 `let` 持有一个联合类型,TypeScript 会在函数调用之间假设它可能被改写,因此拒绝收窄;改用 `const` 或在分支内先赋给新 `const`,收窄就能穿透。

第五种是导入 `type` 与 `interface` 混用导致的结构不一致,以及 `@ts-expect-error` 把真实错误压掉。排查顺序建议固定:先确认变量是不是 `const`,再确认判别字段是不是字面量类型(别用 `string`),最后才考虑 `as` 断言。

type Box = { kind: "a"; a: number } | { kind: "b"; b: string }

// 失败写法:kind 声明成 string,无法收窄
function bad(box: { kind: string }) {
  if (box.kind === "a") {
    // 这里 box.a 不存在,因为 kind 不是字面量类型
  }
}

// 正确写法:用 as 收窄到联合类型
function good(box: Box) {
  if (box.kind === "a") {
    return box.a + 1
  }
  return box.b.length
}

// 排查三连:const 了吗?kind 是字面量吗?分支赋值过吗?

把类型约束接进工程流程

类型设计要落地,离不开三处硬性设置:`strict: true` 全量开启、`noUncheckedIndexedAccess: true` 防止数组与索引访问返回 `undefined`、以及把 `eslint` 的 `@typescript-eslint/no-explicit-any` 与 `ban-ts-comment` 设为 error 让 `any` 和 `@ts-ignore` 无法悄悄混入。

第二个关键是控制公共 API 的导出面。内部函数可以放心用断言,但凡是要跨包复用的类型,都应该是显式声明的接口;不要把后端响应类型直接 `typeof response` 导出,因为内部字段一旦变更,所有下游编译期无感知地坏掉。

最后用 `satisfies` 做双向校验:它要求对象满足目标类型,同时保留推断出的字面量类型。写路由表、事件映射表这类「既要类型正确又不想丢具体键」的场景,`satisfies` 比 `as const` 更安全,因为它不会替你撒谎。

// tsconfig.json 关键项
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true
  }
}

// 路由表:satisfies 同时保证键完整与字面量推断
const routes = {
  home: { title: "首页", auth: false },
  admin: { title: "管理", auth: true },
} satisfies Record<string, { title: string; auth: boolean }>

type RouteKey = keyof typeof routes // "home" | "admin",不丢失具体键

官方参考来源

下方为命令对应的官方权威文档,供你核对最新用法与深入查阅。