TypeScript 6.0 改进了一个很隐蔽的类型推断问题

22 阅读6分钟

TypeScript 6.0 改进了一个很隐蔽的类型推断问题

TypeScript 6.0 里有一个容易被忽略、但很值得理解的类型推断改进:

对没有使用 this 的函数,降低上下文敏感性。

这个改动听起来偏底层,但它解释了一个前端开发里很容易遇到的困惑:

为什么同样是对象里的函数,箭头函数可以正常推断类型,换成方法语法之后,类型推断就可能失败?

这篇文章就围绕这个问题展开。我们不只看 TypeScript 6.0 改了什么,也顺便把背后的几个概念串起来:上下文类型、泛型推断、上下文敏感函数,以及方法语法里那个容易被忽略的隐式 this

从一个泛型推断例子开始

先看一个函数声明:

declare function callIt<T>(obj: {
  produce: (x: number) => T;
  consume: (y: T) => void;
}): void;

produce 返回一个 Tconsume 接收一个 T。所以 produce 的返回值类型,会决定 consume 参数 y 的类型。

下面这段代码很好理解:

callIt({
  produce: (x: number) => x * 2,
  consume: y => y.toFixed(),
});

produce 返回 number,所以 T 被推断成 numberconsume 里的 y 也就是 number

即使把两个属性调换顺序,也没有问题:

callIt({
  consume: y => y.toFixed(),
  produce: (x: number) => x * 2,
});

这说明 TypeScript 并不是简单地按对象属性从上到下处理。它会结合整个对象字面量,尝试为泛型参数 T 找到合适的类型。

到这里,一切都符合直觉。

换成方法语法,问题出现了

如果把箭头函数改成对象方法语法,事情就开始变得微妙。

callIt({
  produce(x: number) {
    return x * 2;
  },
  consume(y) {
    return y.toFixed();
  },
});

这段代码可以正常工作。

但在 TypeScript 6.0 之前,如果把 consume 放到前面,就可能报错:

callIt({
  consume(y) {
    return y.toFixed();
    //     ~
    // error: 'y' is of type 'unknown'.
  },
  produce(x: number) {
    return x * 2;
  },
});

从写代码的人角度看,这两个版本表达的意思非常接近:

callIt({
  consume: y => y.toFixed(),
  produce: (x: number) => x * 2,
});
callIt({
  consume(y) {
    return y.toFixed();
  },
  produce(x: number) {
    return x * 2;
  },
});

都是传入一个对象,都是 produce 返回 number,也都希望 consume 里的 y 被推断成 number

差异来自一个容易被忽略的点:对象方法语法里的函数,可能带有隐式的 this

泛型推断为什么会卡住

先把 this 放一边,单独看泛型推断本身。

function callFunc<T>(callback: (x: T) => void, value: T) {
  return callback(value);
}

callFunc(x => x.toFixed(), 42);

TypeScript 要同时回答两个问题:

  • T 是什么类型?
  • 回调里的 x 是什么类型?

这两个问题互相依赖。

如果已经知道 Tnumber,那 x 就是 numberx.toFixed() 没问题。

但要检查 x => x.toFixed(),又需要先知道 x 的类型。

TypeScript 处理这类场景时,会先跳过某些依赖上下文的函数,优先从其他参数里推断泛型。上面这个例子里,它可以先看第二个参数 42,推断出 Tnumber,再回头检查回调函数。

callFunc(x => x.toFixed(), 42);
//                         ^^
//                         先从这里推断 T 是 number

回头检查时,x 就有了上下文类型 number

这个策略很重要。否则 TypeScript 很容易陷入一种循环:检查回调需要 T,推断 T 又可能需要检查回调。

什么是上下文敏感函数

可以简单理解为,参数没有显式类型、需要靠外部上下文确定参数类型的函数,就是上下文敏感函数。

比如:

consume: y => y.toFixed()

这里 y 没有写类型,TypeScript 必须根据 consume 的预期类型推断它。

方法语法也是一样:

consume(y) {
  return y.toFixed();
}

y 同样没有写类型,也需要上下文。

在泛型推断里,上下文敏感函数会比较棘手。它一边需要泛型推断的结果,一边又可能反过来影响泛型推断。所以 TypeScript 通常会先跳过它们,从更明确的位置找类型信息。

这个策略本身没问题。真正让人困惑的是,箭头函数和方法语法在这里并不完全等价。

隐式 this 为什么会让问题变复杂

箭头函数没有自己的 this

const obj = {
  value: 1,
  method: () => {
    // 这里的 this 不是 obj
  },
};

对象方法语法则可能拥有自己的 this

const obj = {
  value: 1,
  method() {
    return this.value;
  },
};

一旦方法里真的使用了 this,TypeScript 就不能只看方法参数了。它还要知道这个 this 的类型,而 this 的类型往往来自整个对象。

看一个简化例子:

declare function defineFeature<TState>(feature: {
  state: () => TState;
  getLabel(this: TState): string;
}): void;

defineFeature({
  getLabel() {
    return this.count.toFixed();
  },

  state() {
    return {
      count: 1,
    };
  },
});

getLabel 里用了 this.count。TypeScript 检查它时,需要先知道 this 是什么。

this 的类型是 TStateTState 又来自同一个对象里的 state() 返回值。

依赖关系大概是这样:

state() 的返回值 -> TState -> getLabel 里的 this

这就是使用 this 后真正麻烦的地方。方法体不再只依赖自己的参数,还依赖整个对象字面量的类型。TypeScript 需要先理解对象里其他成员,才能安全地检查这个方法。

所以,当方法真的使用了 this 时,TypeScript 保守一点是合理的。它不能太早断定这个方法可以先参与泛型推断,否则可能会在 this 类型还没确定时检查方法体。

但前面的 callIt 例子并没有使用 this

callIt({
  consume(y) {
    return y.toFixed();
  },
  produce(x: number) {
    return x * 2;
  },
});

consume 只用了参数 y,没有访问 this。旧版本里,TypeScript 仍然会因为它是方法语法,认为它“可能”涉及 this,于是更保守地处理它。

这就是旧行为不太符合直觉的地方:真的用了 this,复杂性是合理的;完全没用 this,再因为隐式 this 影响推断,就显得有点多余。

TypeScript 6.0 改了什么

TypeScript 6.0 做的事情很直接:

判断一个函数是否上下文敏感时,会检查它有没有实际使用 this

如果一个方法没有使用 this,TypeScript 就不会仅仅因为它采用了方法语法,就把它当成更复杂的上下文敏感函数。

所以这个例子在 TypeScript 6.0 中可以正常工作:

callIt({
  consume(y) {
    return y.toFixed();
  },
  produce(x: number) {
    return x * 2;
  },
});

因为 consumeproduce 都没有真正使用 this,TypeScript 可以更积极地利用它们参与泛型推断。

这个改动并不是让方法语法完全等同于箭头函数,而是补上了一个更精确的判断:

没有实际使用 this 的方法,不应该因为隐式 this 的存在而被过度保守地处理。

小结

TypeScript 6.0 的这个改动,解决的是一个很细但很真实的类型推断问题。

对象方法语法天然可能带有隐式 this。旧版本里,即使函数体没有实际使用 this,TypeScript 也可能因此更保守地处理它,导致泛型推断失败。

TypeScript 6.0 会更精确地区分这两种情况:真正使用了 this,继续谨慎处理;没有使用 this,就不要让方法语法本身影响推断。

这个改动不需要我们记住新的语法,但它能帮助我们理解 TypeScript 在类型推断里做的一种取舍:既要尊重 JavaScript 的 this 语义,也要尽量让推断结果符合开发者直觉。

参考资料