为什么同一个对象 TypeScript 一会报错一会不报?

TypeScript1周前更新 admin
184 1

TypeScript 的时候,你有没有遇到过这种诡异的情况:

明明是同一个对象,同一个属性,上一行代码里用得好好的,下一行突然就报错了;或者两段看起来长得一模一样的代码,一段能过编译,另一段却直接飘红。

第一反应往往是:”这是不是 TypeScript 的 bug?”

其实大概率不是。TypeScript 的类型检查器并不是简单地”记住一个对象长什么样”,然后从头到尾用同一个标准去检查它。它的判断会跟着代码写法赋值方式执行路径不断变化。今天就把最常见的三种”同一个对象、待遇不同”的情况拆开讲清楚。

场景一:直接传对象字面量,和先存到变量里再传,待遇完全不同

先看一段代码:

ts:
interface Point {
  x: number;
  y: number;
}

function printPoint(p: Point) {
  console.log(p.x, p.y);
}

// 报错:对象字面量只能指定已知属性,并且"z"不在类型"Point"中
printPoint({ x: 1, y: 2, z: 3 });

// 不报错,正常运行
const p = { x: 1, y: 2, z: 3 };
printPoint(p);

两次传进去的对象,内容一模一样,多了一个 z 属性。但第一次报错,第二次没事。

这不是巧合,而是 TypeScript 一个专门的设计,叫多余属性检查(excess property check),也有人叫它”新鲜度检查”。

打个比方:你去寄快递,如果是当场打包、直接递给验货员的包裹(也就是”字面量”,现场新建的对象),验货员会觉得”这东西刚出炉,最能反映你的真实意图”,于是会严格拆开检查,一旦发现多塞了不该有的东西,直接拒收——这对应的就是报错。

但如果你先把东西装进一个纸箱、贴好标签,放在一边(也就是赋值给一个变量),过一会儿再把这个纸箱寄出去,验货员看到的只是一个已经封好的箱子。TypeScript 会转而采用它的核心哲学——结构类型系统:只要这个箱子上”该有的标签都齐了”(该有的属性都满足),就认为可以通过,不会再去拆箱细查里面还多装了什么。

所以结论很简单:多余属性检查只在”直接传入的对象字面量”上生效,一旦对象先被赋值给变量,检查就会放松。 如果你希望这种检查一直生效,可以显式标注变量类型:

ts:
const p: Point = { x: 1, y: 2, z: 3 }; // 这里会报错,因为标注了类型

场景二:类型收窄,会被”中途做点别的事”打断

第二种常见情况和**类型收窄(narrowing)**有关。先看代码:

ts:
interface User {
  name: string;
  age?: number;
}

function greet(user: User) {
  if (user.age !== undefined) {
    console.log(user.age.toFixed(2)); // OK,这里 age 已经被收窄为 number

    doSomething(user); // 中间调用了一个函数

    console.log(user.age.toFixed(2)); // 报错!age 又变回了 number | undefined
  }
}

function doSomething(user: User) {
  user.age = undefined;
}

同一个 user.age,在 if 判断刚结束的那一刻,TypeScript 认为它已经排除了 undefined,是安全的 number;可是一旦中间插入了一次函数调用,再往下用的时候,它又被打回了原形,变成 number | undefined,编译器要求你重新判断一次。

这也不是玄学。TypeScript 的收窄依赖一种叫**控制流分析(Control Flow Analysis)**的机制,简单说就是它会顺着代码执行的路径,一步步推理某个变量在”这一行”具体是什么类型。但这套推理有一个前提:在收窄之后到你使用之前,没有任何东西偷偷改变过这个值。

可以类比成打电话:你一开始已经确认了”电话那头就是这个人”(完成了收窄),但只要你中途去忙了别的事情(调用了一个函数),TypeScript 就没法保证——那个函数会不会正好把这个人的信息改了?既然不能百分百确定,它就会保守地认为”刚才的确认作废了”,逼你重新验证一次。哪怕那个函数实际上什么都没改,只要它的类型签名上允许修改,TypeScript 就会这么处理。

这也是为什么很多人会觉得”这对象我明明才判断过,怎么又报错了”——问题往往不出在对象本身,而是收窄的结果在传递过程中被中间的操作”作废”了。

场景三:解构和展开,会让对象”重新变新鲜”

还有一种更容易被忽视的情况,跟解构、展开运算符(...)有关:

ts:
interface Config {
  mode: "dev" | "prod";
}

function run(c: Config) {}

const base = { mode: "dev" as const, debug: true };

const merged = { ...base };

run(merged); // 报错:merged.mode 的类型被推断成了 string,而不是 "dev"

这里的坑在于字面量类型的拓宽(widening)base.mode 原本借助 as const 被固定成了精确的字面量类型 "dev",但是经过 { ...base } 这样一次展开、重新组装之后,如果没有再次用 as const 锁定,TypeScript 会重新按照”最一般的类型”去推断,"dev" 就被放宽成了普通的 string。而 Config.mode 要求的是精确的 "dev" | "prod"string 显然不满足,于是报错。

打个比方,这就像复印一份合同:内容看起来一模一样,但复印件不再是”原件”,它的效力(这里指类型的精确度)需要重新确认,不能想当然地认为复印件和原件享有完全一样的待遇。

小结:三条线索,帮你快速定位问题

下次再遇到”同一个对象,为什么这里报错那里不报”,可以按这个顺序排查:

  1. 是不是直接字面量 vs 先赋值给变量? —— 多余属性检查只认”新鲜”的字面量,装进变量之后就会放松。
  2. 收窄和使用之间,是不是插了别的操作? —— 函数调用、重新赋值,甚至访问了 getter 属性,都可能让收窄结果失效,需要重新判断。
  3. 对象是不是被解构或展开过? —— 这会重置对象的”新鲜度”,也可能让字面量类型被拓宽,导致原本兼容的类型突然不匹配。

理解了这三点,你会发现 TypeScript 并不是在”随缘报错”,它其实一直在用一套非常严谨(甚至有点较真)的逻辑,尽力在编译阶段帮你拦住那些可能出问题的地方。搞懂它的脾气之后,这些”一会报错一会不报”的诡异现象,也就没那么诡异了。

© 版权声明

相关文章

1 条评论

  • A WordPress Commenter

    Hi, this is a comment.
    To get started with moderating, editing, and deleting comments, please visit the Comments screen in the dashboard.
    Commenter avatars come from Gravatar.

    回复