一个经常发生但不太被重视的瞬间
你大概率写过这样的代码:
- 从
route.query.id里取id - 把它转成
number - 然后继续请求接口/查列表/打开详情页
当一切顺利时,它看起来很自然;当它不顺利时,你会看到两类“修复方式”:
- 强转把错误压下去:
route.query.id as string - 强行骗过编译器:
route.query.id as unknown as number
问题是:这些写法并没有让数据更可靠,它们只是让编辑器不再提醒你“这里不确定”。
这篇文章想做的是把不确定性放回它应该待的位置:边界层。边界层负责把输入从“不可控”变“可控”,业务层才能更干净、更少分支、更少强转,也更适合长期迭代。
TS 在团队里解决的不是“类型”,而是“确定性”
在个人项目里,TS 有时更像“仪式感”;在团队里,它是把协作成本显性化的一种方式。
- 需求变动时:字段改名、可选变必填、枚举新增分支,TS 能把影响面直接标出来。
- 重构时:你更敢删代码、更敢改数据结构,因为编译器会帮你找遗漏。
- Code Review 时:类型比注释更可信,它是能被工具验证的约束。
但前提是:你得让 TS 有机会说话,而不是一遇到“不确定”就把它静音。
正确认识 any、unknown、never:三种“态度”
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/组件都存在“二选一”的输入:
- 传
id或slug - 传
value或modelValue - 传
path或name
用类型表达约束能让调用方更早犯错。
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:减少“用断言掩盖配置错误” - 强转多是信号:说明收口/建模/推断没做好,应该回到边界层修结构