类型收窄先控边界

一次前端线上问题,最后定位到的代码只有一行:user.profile.name.trim()。页面里的用户信息来自一个老接口,文档上写着 profile 一定存在,测试环境里也一直存在。可线上有一批历史用户没有补全资料,接口返回的 profile null。TypeScript 文件里类型写得很漂亮,编译也没有报错,真正运行时还是炸了。
这类问题最容易让人误会 TypeScript。有人会说:“既然用了类型,为什么还会空指针?”答案其实不复杂:TypeScript 只能检查你告诉它的类型,不能保证接口真实返回一定符合声明。只要边界没守住,业务代码里再漂亮的类型都可能只是自我安慰。
我更愿意把类型收窄看成一件工程上的事,而不是语法技巧。它不是为了让编辑器变聪明,也不是为了炫耀几个复杂的泛型。它的价值在于:当数据从接口、缓存、表单、URL、localStorage、第三方 SDK 或埋点回放里进入业务逻辑时,我们先把“不确定”关在门外,再让确定的数据继续往下走。
上面这张图没有放文字,是为了强调一个动作:未知输入不能直接流进业务模块,它要先被拆成几条可判断的路径。每条路径要么进入明确类型,要么被拦截,要么进入兜底处理。
真正危险的是“看起来已经有类型”
TypeScript 项目里最容易出问题的地方,不一定是完全没有类型的代码,而是那些“看起来已经有类型”的代码。比如接口返回被直接断言成 User,表单数据被 as FormData 包了一层,JSON.parse 的结果被赋给一个业务对象。编辑器安静了,风险也被盖住了。
下面这种写法很常见:
  1. type User = {
  2. id: string
  3. profile: {
  4. name: string
  5. }
  6. }

  7. async function loadUser() {
  8. const res = await fetch('/api/user')
  9. const data = await res.json() as User
  10. return data.profile.name.trim()
  11. }
ts
问题不在 User 这个类型定义,而在 as User。它不是校验,只是告诉 TypeScript:“相信我,这就是 User。”如果接口返回了别的形状,编译器不会再追问。这个断言把未知数据伪装成确定数据,后面的业务逻辑自然会放松警惕。
很多团队的类型问题不是不会写类型,而是太早把不确定数据命名成确定类型。接口刚返回时,它更接近 unknown,而不是业务对象。只有经过必要检查后,它才有资格成为 User。这个边界意识一旦建立,很多空值、字段缺失、枚举不匹配、数组元素结构错误都会更早暴露。
我通常会把数据分成三层:外部输入、运行时校验后的中间数据、业务内真正使用的类型。外部输入不要急着断言;中间数据负责把字段存在性、基础类型、枚举范围检查清楚;业务类型则应该尽量稳定,让业务代码少处理“不知道会不会存在”的情况。
unknown any 更像一个门卫
any 最大的问题不是灵活,而是它会把检查关闭。一个值只要变成 any,你就可以访问它的任意字段,调用任意方法,把它传给任意函数。它像一张万能通行证,短期方便,长期会让风险在代码里到处流动。
unknown 的气质完全不同。它也能承接任何值,但在你使用它之前,必须先证明它是什么。也就是说,unknown 不是为了增加麻烦,而是强迫你在边界处做判断。
  1. function readName(input: unknown): string {
  2. if (
  3. typeof input === 'object' &&
  4. input !== null &&
  5. 'profile' in input
  6. ) {
  7. // 这里还不能直接认为 profile.name 一定存在
  8. }

  9. return '匿名用户'
  10. }
ts
这段代码看起来比 as User 麻烦,但它逼你承认一件事:只判断对象存在还不够,里面的嵌套字段也需要确认。TypeScript 的收窄是逐步发生的,你检查了什么,它就相信什么;你没检查的,它不会替你脑补。
这也是 unknown 适合放在边界处的原因。API 响应、消息队列 payload、浏览器存储、URL 参数、低代码配置、第三方 SDK 回调,这些地方都应该被当成未知输入。进入内部业务函数之后,类型应该已经被收窄,代码才会清爽。
有些项目为了省事,把接口请求函数直接写成泛型:
  1. async function request<T>(url: string): Promise<T> {
  2. const res = await fetch(url)
  3. return res.json() as Promise<T>
  4. }
ts
这个函数很好用,也很危险。调用方写 request<User>() 时,真实校验并没有发生,只是把断言藏进了公共函数。它适合内部服务稳定、接口契约可靠、后端和前端强绑定的场景;如果接口来自多个系统、字段经常演进,或者历史数据很多,就不该把它当成安全保障。
类型守卫要写在业务边界,不要散落在页面里
类型守卫是一种常见收窄方式。比如写一个函数判断某个值是不是 User
  1. type User = {
  2. id: string
  3. profile: {
  4. name: string
  5. }
  6. }

  7. function isUser(value: unknown): value is User {
  8. if (typeof value !== 'object' || value === null) return false
  9. const record = value as Record<string, unknown>
  10. const profile = record.profile

  11. if (typeof record.id !== 'string') return false
  12. if (typeof profile !== 'object' || profile === null) return false

  13. const profileRecord = profile as Record<string, unknown>
  14. return typeof profileRecord.name === 'string'
  15. }
ts
这段代码不算优雅,但它有一个明确优点:它把边界检查集中起来了。业务代码可以先调用 isUser(data),通过后再进入用户逻辑。这样页面组件不需要到处写 user?.profile?.name ?? '',也不用每个地方都重复猜字段是否存在。
类型守卫写在哪里很关键。不要把它们散落在组件渲染里。组件里到处出现 typeofinArray.isArray,通常说明边界没有被集中处理。更好的位置是请求适配层、状态初始化层、表单提交前、消息消费入口,或者从缓存恢复数据的地方。
当然,手写类型守卫也有成本。字段很多时容易漏;类型变了守卫没更新;嵌套结构复杂时可读性会下降。对于简单对象,手写守卫足够;对于复杂接口,可以考虑用 Zod、Valibot、io-ts 这类运行时 schema 工具。但要注意,工具不是越早引入越好。如果项目只有几个简单接口,上来就铺一套 schema 体系,反而会增加维护负担。
我的判断标准是:当同一个外部数据结构被多个模块使用,或者一次字段错误会造成明显业务损失,就值得把运行时校验做成公共能力。只是页面展示一个可选昵称,手写兜底就够;如果是订单金额、权限、库存、活动规则,就应该认真校验。
图里三层结构对应的是:外部数据、校验边界、业务模型。中间这一层越清楚,业务代码里越少出现到处试探的写法。
判别联合适合表达“只能走一种状态”
类型收窄不只发生在接口数据上,也发生在业务状态上。很多页面状态一开始会写成几个布尔值:loadingerrorsuccessempty。写到后面就出现奇怪组合:既 loading error,既 success empty。TypeScript 也很难帮你,因为你给它的状态模型本来就允许这些组合。
判别联合更适合表达互斥状态:
  1. type PageState =
  2. | { status: 'idle' }
  3. | { status: 'loading' }
  4. | { status: 'success'; user: User }
  5. | { status: 'error'; message: string }

  6. function render(state: PageState) {
  7. switch (state.status) {
  8. case 'idle':
  9. return '等待加载'
  10. case 'loading':
  11. return '加载中'
  12. case 'success':
  13. return state.user.profile.name
  14. case 'error':
  15. return state.message
  16. }
  17. }
ts
这里的 status 就是判别字段。进入 success 分支后,TypeScript 知道 user 一定存在;进入 error 分支后,它知道 message 存在。这比到处写可选字段更干净,也更符合业务真实状态。
判别联合的价值不只是少写判断。它能让非法状态在类型层面不存在。比如一个页面不应该同时有成功数据和错误消息,那就不要把状态写成 { loading?: boolean; data?: User; error?: string }。这种对象太宽松,任何组合都能出现,后面只能靠人脑记规则。
但判别联合也不是万能。状态太多时,联合类型会变得臃肿;状态之间如果有大量共享字段,也可能写起来重复。这个时候可以拆成核心状态和辅助字段,或者把复杂流程抽成状态机。关键不是追求类型形式好看,而是让类型能准确表达“哪些组合被允许,哪些组合不该出现”。
表单和 URL 参数要比接口更谨慎
很多人会认真校验接口,却轻视表单和 URL 参数。其实这两个入口更不稳定。用户可以手动改 URL;浏览器恢复草稿时可能带旧字段;表单组件返回值可能因为空选择、清除按钮、异步默认值而变成 undefined;多选框可能返回空数组,也可能被某个组件库返回 null
举个例子,后台列表页从 URL 读取筛选条件:
  1. type Filter = {
  2. page: number
  3. keyword: string
  4. status: 'all' | 'enabled' | 'disabled'
  5. }
ts
这个类型在业务内部很好,但 URL 里读出来的永远先是字符串或空值。page=abcpage=-1status=deleted 都可能出现。如果你直接把 URLSearchParams 转成 Filter,页面可能请求出奇怪数据,甚至把错误状态同步回地址栏。
更稳的方式是把 URL 参数解析成一个单独函数,明确默认值和非法值处理:
  1. function parseStatus(value: string | null): Filter['status'] {
  2. if (value === 'enabled' || value === 'disabled') return value
  3. return 'all'
  4. }

  5. function parsePage(value: string | null): number {
  6. const page = Number(value)
  7. if (!Number.isInteger(page) || page < 1) return 1
  8. return page
  9. }
ts
这些函数看起来普通,却是很实在的类型收窄。它们把外部字符串变成了业务可用值。业务层拿到 Filter 后,就不用再担心 page 是不是 NaN,status 是不是奇怪枚举。
表单也是同理。表单提交前应该有一层归一化:空字符串要不要转成 undefined,日期要不要转成时间戳,金额要不要处理精度,数组要不要过滤空项。这些规则如果散落在提交函数里,后面很难维护;如果集中成 parse normalize 函数,类型和业务就会更稳定。
不要让类型收窄变成“满屏 if”
说到这里,可能会有一个担心:是不是每个字段都要写一堆判断?不是。类型收窄的目标是把风险集中,而不是让每行代码都变成防御式编程。
一个实用原则是:边界处严格,内部放松。外部输入进入系统时认真校验;校验通过后,内部函数就相信这个类型。否则你会得到一种很疲惫的代码风格:每个函数都在检查同一件事,每个组件都在处理同一批空值,类型没有带来清爽,反而带来噪音。
另一个原则是:高风险字段严格,低风险字段轻量。金额、权限、状态、数量、时间范围、业务枚举,这些字段错了通常会造成实际损失,应该认真收窄。展示文案、可选头像、非关键备注,可以用默认值和兜底处理,不一定要写复杂 schema。
还有一个容易忽略的点:类型收窄要和错误处理配套。校验失败后怎么办?丢弃、展示提示、上报日志、使用默认值、进入降级逻辑,还是阻止提交?如果只写了守卫,却没有处理失败路径,代码只是把问题从一处挪到另一处。
我的经验是给失败路径分级:用户输入非法,直接提示用户;接口结构异常,记录日志并走降级展示;关键业务数据异常,阻止操作并暴露给监控;历史脏数据异常,尽量给出兼容策略并推动数据修复。不同失败不应该都用一句“数据异常”打发。
发布前看三件事是否真的生效
类型相关改动发布前,不能只看编译通过。编译通过只能说明静态类型没有明显冲突,不说明边界处理一定正确。我会额外看三件事。
第一,看未知输入是否还会直接进入业务类型。可以搜索 as SomeTypeJSON.parse、请求泛型、localStorage 读取、URL 参数解析。不是说这些写法都错,而是要确认它们后面有没有校验或归一化。
第二,看关键状态是否还有非法组合。页面状态、订单状态、任务状态、权限状态,如果仍然靠多个布尔值拼起来,就要检查是否可能出现矛盾组合。能用判别联合表达的状态,尽量让类型帮你排除非法路径。
第三,看失败路径有没有证据。校验失败是否能复现,日志里能否看到原因,用户界面是否有合理提示,监控里能否看到异常数量。没有证据的类型保护,很难在上线后判断是否真的挡住了问题。
这张路线图想表达的是:类型工作不是写完就结束,它要经过输入、校验、业务、发布观察几个检查点。任何一个点被跳过,问题都可能绕回运行时。
最后留一个判断标准:如果某段代码必须依赖“接口一定返回正确”“这个字段理论上不会为空”“用户不会这样输入”才能安全运行,那它就还没有完成收窄。TypeScript 真正好用的时候,往往不是类型写得最复杂的时候,而是不确定性被关在最少几个边界里的时候。