TS 在团队协作中到底有什么用?

52 阅读10分钟

一个经常发生但不太被重视的瞬间

你大概率写过这样的代码:

  • route.query.id 里取 id
  • 把它转成 number
  • 然后继续请求接口/查列表/打开详情页

当一切顺利时,它看起来很自然;当它不顺利时,你会看到两类“修复方式”:

  • 强转把错误压下去route.query.id as string
  • 强行骗过编译器route.query.id as unknown as number

问题是:这些写法并没有让数据更可靠,它们只是让编辑器不再提醒你“这里不确定”。

这篇文章想做的是把不确定性放回它应该待的位置:边界层。边界层负责把输入从“不可控”变“可控”,业务层才能更干净、更少分支、更少强转,也更适合长期迭代。


TS 在团队里解决的不是“类型”,而是“确定性”

在个人项目里,TS 有时更像“仪式感”;在团队里,它是把协作成本显性化的一种方式。

  • 需求变动时:字段改名、可选变必填、枚举新增分支,TS 能把影响面直接标出来。
  • 重构时:你更敢删代码、更敢改数据结构,因为编译器会帮你找遗漏。
  • Code Review 时:类型比注释更可信,它是能被工具验证的约束。

但前提是:你得让 TS 有机会说话,而不是一遇到“不确定”就把它静音。


正确认识 anyunknownnever:三种“态度”

any:不是“我知道是什么”,而是“别管我”

any 最大的问题不是“不严谨”,而是它会传播

一旦某个输入变成 any,后续所有计算、属性访问、函数调用都可能在不报错的情况下继续前进,直到线上某个分支被击中。

团队实践上更好的定位是:

  • any 当作“紧急通行证”
  • 只允许出现在:
    • 第三方库类型缺失且短期不可补齐
    • 大规模迁移期的过渡层(并且要有收口计划)
  • 在业务核心链路上尽量避免它

unknown:最诚实的起点

unknown 的意义是:我承认这里不确定,但我会在使用之前把它证明成确定。

这在 Vue3 项目里很常见:

  • route.query(URL 输入)
  • 接口返回(跨服务输入)
  • localStorage(持久化输入)
  • window/document 上的扩展属性(环境输入)

这些输入不是“写死的本地变量”,它们是世界给你的,默认就该从 unknown 开始思考,而不是从“我希望它是 string”开始。

never:让“遗漏分支”变成编译期问题

never 常用于两件事:

  • 穷尽检查:联合类型新增分支时,提醒你补齐逻辑
  • 不可达证明:告诉读者和工具“这里不应该发生”

你可以把它理解为:在团队协作里,用 never 把“你肯定不会遇到”的话,变成“工具可以验证不会遇到”。


route.query 从“不可信”收口成“可用”

route.query 的本质是 URL 参数,它天然不可靠:

  • 可能没有传:undefined
  • 可能重复传:string[]
  • 可能传了空字符串、非法字符、甚至恶意内容

如果你的业务需要的是 number,那就意味着你必须做两件事:

  • 运行时校验:把不合法输入挡在边界层
  • 类型层表达:让后续代码拿到的就是确定类型

先从最小的类型守卫开始

不要让 as string 成为你的第一反应。更可维护的方式是写可复用的 guard:

export function isString(x: unknown): x is string {
  return typeof x === 'string'
}

export function isStringArray(x: unknown): x is string[] {
  return Array.isArray(x) && x.every((i) => typeof i === 'string')
}

这两段代码的价值不在于“写起来高级”,而在于:

  • 它们把“证据”写成了函数
  • 任何人都能复用
  • 你在 review 时能清楚看到“为什么这里变成 string 了”

把 query 的收口做成一个小工具

业务里最常见的是“取一个 query 参数并保证它是单个字符串”。可以定义一个收口函数:

export function getSingleQueryString(x: unknown): string | undefined {
  if (typeof x === 'string') return x
  if (Array.isArray(x)) return x[0]
  return undefined
}

然后针对常见需求再包一层:例如取数字 id

export function getQueryNumber(x: unknown): number | undefined {
  const s = getSingleQueryString(x)
  if (s == null) return undefined
  if (!/^\d+$/.test(s)) return undefined
  const n = Number(s)
  return Number.isFinite(n) ? n : undefined
}

这样业务代码会变得很“单纯”:

  • 你拿到的是 number | undefined
  • 你只需要处理“有没有 id”,而不是处理一堆类型分支
  • 更不会出现 as unknown as number

类型缩窄(Type Narrowing)不是花活,是“证据链”

上面几段代码其实只做了一件事:把“不确定”一步步缩窄成“确定”。

常用的缩窄手段在这里非常自然:

  • typeof x === 'string'
  • Array.isArray(x)
  • x != null
  • 业务校验:正则、Number.isFinite、范围判断等

把这些校验集中在边界层,你的业务逻辑会得到一个好处:分支更少、强转更少、边界更清晰

结合 unplugin-vue-router:它能帮你,但不会替你收口 query

unplugin-vue-router 往往能让“路由结构相关”的类型更聪明,比如某些路由 name、params 的推断。但无论有没有它,query 仍然是 URL 输入:

  • 它属于“外部世界”
  • 它不会因为你用了更强的路由工具就自动变可靠

因此实践上很推荐把 query 的收口当作一套团队基础设施:统一 guard/parse,统一处理策略(缺失就兜底?非法就报错?)。


as 的边界:断言不是“类型转换”,是“誓言”

很多 TS 使用体验变差,来自于对 as 的误解。

as 不会改变运行时的值

这句话值得反复强调:as 只发生在类型层,它不会把 "123" 变成 123,也不会把 undefined 变成你想要的值。

所以 as 的正确使用场景是:

  • 你已经有运行时证据
  • 你是在把证据告诉编译器

例如你前面做了 typeof x === 'string',此时不需要 as string,编译器已经知道了。真正需要断言的情况往往更少,且更具体。

警惕 as unknown as T:它通常意味着“我不想处理”

X as unknown as T 的本质是:

  • 第一次把类型抬到 unknown(绕过结构检查)
  • 第二次直接落到目标类型 T(绕过合理性)

它可以在极少数边界场景救火,但在业务代码里大量出现时,通常说明:

  • 输入边界没收口
  • 类型模型设计失败(太宽/太窄)
  • 本该是“解析 + 校验”的问题,被用“强转”掩盖了

一个简单但很有效的团队共识是:

  • 业务代码里尽量禁止 as unknown as T
  • 如果必须使用,只允许在边界层,并说明原因与风险

接口返回类型,从 unknown 起步更安全

很多人对接口返回类型的直觉是:后端给了字段文档,前端写个类型就完了。

现实是:

  • 后端灰度期间字段可能不一致
  • 某些字段可能为空、为 null、为字符串数字
  • 错误时可能返回另一种结构

把接口返回当成可信输入,最后往往就是“业务里到处判空 + 到处强转”。

更可持续的写法是分层:

  • 边界层(API 层)unknown -> parse/validate -> 得到稳定类型
  • 业务层:只处理稳定类型,不关心“数据怎么来的、脏不脏”

最小落地方式:写解析器,不必立刻引入大框架

你可以先不引入复杂的 schema 工具,先从“解析函数”开始,把不可信输入收口:

type User = {
  id: number
  name: string
}

export function parseUser(x: unknown): User | undefined {
  if (typeof x !== 'object' || x == null) return undefined

  const obj = x as Record<string, unknown>
  const id = typeof obj.id === 'number' ? obj.id : undefined
  const name = typeof obj.name === 'string' ? obj.name : undefined

  if (id == null || name == null) return undefined
  return { id, name }
}

这里你会注意到一个微妙点:as Record<string, unknown> 是一种“受控断言”——你没有直接断言成 User,你断言成了更保守的结构,然后通过缩窄/校验把它变成 User

这比一上来 x as User 的差别在于:你把证据写出来了。

让边界层失败得更明确

在接口层如果解析失败,你可以选择:

  • 直接抛错(让错误早暴露)
  • 返回 undefined 并统一兜底
  • 上报埋点并返回降级数据

关键不是选哪一种,而是:不要把失败“带进业务层”,否则业务层每个点都得为边界失败买单。


satisfies + as const:把“配置错误”变成编译期错误

Vue3 团队往往有大量“表驱动”的代码:

  • 路由 meta 映射
  • 权限 key -> 文案/策略
  • 状态 -> 展示/行为配置

这类对象最怕两件事:

  • 写漏字段、写错字段名
  • 字面量被扩大成 string,导致补全与约束失效

as const 能保住字面量,satisfies 能校验结构,但不破坏推断。

你想达到的效果通常是:

  • 配置对象的 key/value 仍保持字面量类型(便于自动补全)
  • 同时它必须满足某个结构(便于团队约束与代码生成/消费)

一句话概括:

  • as Type 更像“我宣称它是”
  • satisfies Type 更像“请你验证它符合”

当你的团队大量依赖配置与约束时,satisfies 往往比 as 更像“工程化”的选择。


把 TS 当成“面向生产的语言”来写

TS 不参与运行,但你写 TS 的方式会直接影响运行时代码的质量。一个很实用的心智模型是:

  • 写 TS 时同时在脑中运行两套解释器:
    • TS 编译器:帮你看见结构、分支、遗漏
    • JS 解释器:提醒你运行时到底可能拿到什么

当你看到自己在某个地方写了很多强转、很多“我觉得它应该是”,往往是一个信号:你应该停下来,把边界收口做扎实。


附录:三组“偏实用”的类型体操(用于约束与可读性)

这一部分不追求炫技,追求两件事:

  • 让类型提示更好读(利于 review、利于维护)
  • 把“调用约束”写进类型里(利于长期协作)

Prettify<T>:让类型变得可读

很多时候你组合了几个工具类型、交叉类型后,IDE 提示会变得又长又难看。Prettify 的目标是把它“展开成更直观的对象形态”。

export type Prettify<T> = { [K in keyof T]: T[K] } & {}

使用场景:

  • API 返回类型经过多层组合后难读
  • Vue 组件 props 类型被交叉/扩展后提示难看
  • 你希望把最终类型以“扁平结构”展示出来方便理解

这类体操的价值非常朴素:减少误读

RequireKeys<T, K> / OptionalKeys<T, K>:表达“同一对象在不同阶段的形态”

在业务里你经常遇到:

  • 创建时必须有 name
  • 更新时 name 可选
  • 某些字段在特定状态下必须存在

用工具类型表达这类约束,比在注释里写“这个字段更新时可以不传”更可靠。

export type RequireKeys<T, K extends keyof T> =
  T & { [P in K]-?: T[P] }

export type OptionalKeys<T, K extends keyof T> =
  Omit<T, K> & Partial<Pick<T, K>>

XOR<T, U>:二选一约束(组件 props / API 参数)

很多 API/组件都存在“二选一”的输入:

  • idslug
  • valuemodelValue
  • pathname

用类型表达约束能让调用方更早犯错。

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }
export type XOR<T, U> =
  (T & Without<U, T>) | (U & Without<T, U>)

注意边界:

  • XOR 让类型更强,但也可能让类型报错信息更复杂
  • 一旦团队成员难以理解,就应该用更简单的约束方式(例如拆成两个 API/两个 props 形态)

团队落地建议:从这几条开始就够了

  • 边界层默认不可信route.query、接口返回、localStorage 从 unknown 思维起步
  • 缩窄优先:先写 typeof/Array.isArray/guard,再写业务逻辑
  • 断言慎用:尽量不用 as unknown as T;如果用了,放在边界层并说明原因
  • 配置表优先用 satisfies:减少“用断言掩盖配置错误”
  • 强转多是信号:说明收口/建模/推断没做好,应该回到边界层修结构