likes
comments
collection
share

TypeScript 5.4 beta: NoInfer 类型、闭包类型分析优化、条件类型判断优化等

作者站长头像
站长
· 阅读数 2

TypeScript 已于 2024.1.23 发布 5.4 beta 版本,你可以在 5.4 Iteration Plan 查看所有被包含的 Issue 与 PR。如果想要抢先体验新特性,执行:

$ npm install typescript@beta

来安装 beta 版本的 TypeScript,或在 VS Code 中安装 JavaScript and TypeScript Nightly ,并选择为项目使用 VS Code 的 TypeScript 版本,来更新内置的 TypeScript 支持:

TypeScript 5.4 beta: NoInfer 类型、闭包类型分析优化、条件类型判断优化等

NoInfer 工具类型

如果你经常使用 TypeScript 的泛型,可能会发现一个不那么符合直觉的地方:在函数签名中,如果有多个参数类型引用同一个泛型参数,那么其表现行为会是,这个泛型参数被推导为一个能够尽可能满足所有参数类型的类型(即在类型层级上表示为所有参数类型的公共父类型)。

来看实际的例子:

function checkLevel<T extends string>(levels: T[], defaultLevel?: T) {}

// 泛型参数推导:checkLevel<"easy" | "normal" | "hard">
checkLevel(['easy', 'normal', 'hard'], 'easy');

// 泛型参数推导:checkLevel<"easy" | "normal" | "hard" | "hell">
checkLevel(['easy', 'normal', 'hard'], 'hell');

我们预期的效果是,从第一个参数推导出所有可用的 levels 的联合类型,然后第二个默认值的类型也应当来自于这个联合类型才对,事实却是 TypeScript 把第二个参数推导出的类型也塞进了泛型类型里。

这个场景下,字面量类型的公共父类型直接就是联合类型,而另外一种常见的场景则是存在显式继承关系的类型:

class Animal {
  move;
}
class Dog extends Animal {
  woof;
}

function doSomething<T>(value: T, getDefault: () => T) {}

// 泛型推导为 doSomething<Animal>;
doSomething(new Dog(), () => new Animal());

要解决这里的两个推导问题其实也很简单,只需要为第二个参数多声明一个泛型,然后使这个泛型约束到第一个泛型即可:

function checkLevel<T extends string, K extends T>(
  level: T[],
  defaultLevel?: K
) {}

// 类型“"hell"”的参数不能赋给类型“"easy" | "normal" | "hard" | undefined”的参数。
checkLevel(['easy', 'normal', 'hard'], 'hell');

看起来没问题,但实际上这个新的泛型参数 K 大概率是一个无意义的泛型参数——如何判断一个泛型参数是否有意义?看它有没有被消费就对了,这里 checkLevel 的返回值类型应当会依赖 T ,但不会依赖 K:

function checkLevel<T extends string, K extends T>(
  level: T[],
  defaultLevel?: K
): T { }

如果只是为了类型约束而单独声明一个泛型参数,其实并不是很好的实践,因此 TypeScript 5.4 版本引入了内置工具类型 NoInfer (intrinsic),用于在这种情况下阻止泛型参数的推断:

function checkLevel<T extends string>(level: T[], defaultLevel?: NoInfer<T>) {}

// 类型“"hell"”的参数不能赋给类型“"easy" | "normal" | "hard" | undefined”的参数。
checkLevel(['easy', 'normal', 'hard'], 'hell');

在这种情况下,TypeScript 不会再将这个参数的类型合并到泛型中,而是会使用泛型参数已获得的类型,来对这个参数进行类型检查。

作为一个工具类型,NoInfer 还可以在条件类型语句中使用。先看不使用 NoInfer 的例子:

type Foo<T> = T extends { a: infer U; b: infer U } ? U : never;

type Foo1 = Foo<{ a: string; b: string }>; // string
type Foo2 = Foo<{ a: string; b: number }>; // string | number

和函数中一样,infer 类型 U 会被推导为所有引用位置的联合类型,现在来加一个 NoInfer 试试:

type Bar<T> = T extends { a: infer U; b: NoInfer<infer U> } ? U : never;

type Bar1 = Bar<{ a: string; b: string }>; // string
type Bar2 = Bar<{ a: string; b: number }>;

第一眼看上去,这个时候 Bar2 的类型也是 string 才对,因为 NoInfer 只阻止了第二处的泛型推导,并没有阻止第一处。但实际上 Bar2 的类型是 never,我个人理解是,在这种情况下 NoInfer 会导致条件类型的条件不成立,模式匹配根本就没有发生,直接走了 false 的 never 类型。

闭包类型 CFA 优化

此前这个系列的文章使用过很多不同的方式来描述「类型收窄」这个行为,比如类型分析、类型收窄、类型推断等等,后续为了统一概念,将全部归纳为「类型控制流分析」(的优化),并简称为 TCFA(Typing Control Flow Analysis)

两年前我在 TypeScript 中的类型控制流分析演进 分析过 TypeScript 从 2.0 版本到 4.6 版本一路下来的 TCFA 优化,你可以很明显感觉到,TypeScript 正在变得越来越聪明。在上一个版本中 TypeScript 优化了 switch(true) 、使用布尔值比较的类型守卫下的 TCFA 表现:

function f(x: unknown) {
    switch (true) {
        case typeof x === "string":
            // 'x' is 'unknown' here.
            console.log(x.toUpperCase());
        case Array.isArray(x):
            // 'x' is 'unknown' here.
            console.log(x.length);
        default:
          // 'x' is 'unknown' here.
    }
}

function isString(x: any): x is string {
    return "toUpperCase" in x;
}

function someFn(x: unknown) {
    if (isString(x)) {
        console.log(x); // string
    }

    if (isString(x) === true) {
        console.log(x); // unknown before 5.3, string since 5.3
    }
}

而在 5.4 版本,TypeScript 对闭包下的 TCFA进行了优化。

先来看一个简单的例子:

function f1() {
  let x: string | number;

  x = 'abc';
  console.log(x); // string

  x = 42;
  console.log(x); // number
}

在为具有联合类型的变量赋值后,TypeScript 能够分析出这个变量接下来的类型(直到下一次赋值),但如果赋值后不是在当前的作用域内,而是在一个闭包内访问,此前 TypeScript 是无法分析出类型的,因为你无法确定 action 是在什么时候调用回调函数的。

declare function action(cb: () => void): void;

function f1() {
  let x: string | number;

  x = 'abc';
  action(() => {
    console.log(x); // string | number
  });

  x = 42;
  action(() => {
    console.log(x); // string | number
  });
}

TypeScript 5.4 版本为这个场景下的 TCFA 进行了优化,现在它能够正确分析出这个变量最后一次赋值以后的类型——此时变量的类型不会再发生改变了,即闭包捕获的值已经固定,在上面的例子中就是这样的:

declare function action(cb: () => void): void;

function f1() {
  let x: string | number;

  x = 'abc';
  action(() => {
    console.log(x); // string | number
  });

  x = 42;
  action(() => {
    console.log(x); // number
  });
}

但需要注意的是,如果这个变量在另外一个嵌套的函数中有赋值语句,那么这个分析就失效了:

declare function action(cb: () => void): void;

function f1() {
  let x: string | number;

  x = 'abc';

  setTimeout(() => {
    x = 42;
  }, 500);

  action(() => {
    console.log(x); // string | number
  });
}

这是因为,TypeScript 并不能知道这个赋值的嵌套函数,是在什么时候调用的,因此也就无法确定真正的最后一次赋值。

看一个更接地气的例子:

function appendUrlParams(url: string | URL, params: Record<string, number>) {
  if (typeof url === 'string') {
    url = new URL(url);
  }

  Object.entries(params).forEach(([param, value]) => {
    // Property 'searchParams' does not exist on type 'string | URL'. error before 5.4, now ok.
    url.searchParams.set(param, value.toString());
  });

  return url.toString();
}

在 5.4 版本,TypeScript 现在能够分析出 Object.entries(params).forEach 中使用的 url 一定是 URL 类型。

这一优化实际上对所有作用域捕获都会生效——除了会享有作用域提升的函数声明、类声明以外:

function f2() {
  let x: string | number;
  x = 42;
  let a = () => {
    x; /* number */
  };
  function g() {
    x; /* string | number */
  }
}

当然,类型收窄并不仅仅对联合类型生效,隐式具有 any 类型的变量同样能够生效:

declare function action(cb: () => void): void;

function f3() {
  let x;

  x = 'abc';
  action(() => {
    x; // any
  });

  x = 42;
  action(() => {
    x; // number
  });
}

Throw Expression

此特性是对 TC39 提案 proposal-throw-expression 的支持,其目前处于 stage 2 阶段,由 TypeScript 团队的 Ron Buckton 提出。实际上,在 5.3 Iteration Plan 中就已经包括此特性,但其没能够在 5.3 版本正式发布,在这里出现是因为它将在 5.4 版本中发布吗?也不是 :-),和 5.2 版本一样,它出现在了 Iteration Plan 中,但没有出现在 DevBlog 中,但谁让我写都写了呢。

Throw Expression 提案允许你像使用表达式一样来使用一个 throw 语句,举例来说,此前我们写一个对黑名单用户抛出错误的逻辑可能是这样的:

const safeUser = isSafeUser();

if(!safeUser){
  throw new Error('...')
}

我们需要将 throw 语句写在单独的代码块里才能正常执行,而现在使用 Throw Expression,你可以直接这么写:

isSafeUser() ? void 0 : throw new Error('...');

你也可以使用它来进行“赋值”:

const user = isSafeUser() || throw new Error('...');

可以这么理解,Throw Expression 并不是真的把这个错误赋值给了一个变量,而是当你的代码执行到这个 Throw Expression 时,会执行这个 throw 语句而已。可以把它应用在各种默认值场景下:

function readFileSync(path = throw new PathNotProvidedError()) { };

function getEncoder(encoding) {
  const encoder = encoding === "utf8" ? new UTF8Encoder()
    : encoding === "utf16le" ? new UTF16Encoder(false)
      : encoding === "utf16be" ? new UTF16Encoder(true)
        : throw new Error("Unsupported encoding");
};

Throw Expression 目前在 Babel 中被实现为一元表达式节点,即 UnaryExpression,就像 const visitor = !userLogin 中的 !userLogin 一样。UnaryExpression 的核心节点包括 operator 与 argument,在这里分别是 !throwuserLoginnew Error()

另外,Throw Expression 编译的降级产物实际上是一个 IIFE ,比如上面的例子的编译结果大致是这样的:

function readFileSync(path = function (e) {
  throw e;
}(new PathNotProvidedError())) {}
;

function getEncoder(encoding) {
  const encoder = encoding === "utf8" ? new UTF8Encoder() : encoding === "utf16le" ? new UTF16Encoder(false) : encoding === "utf16be" ? new UTF16Encoder(true) : function (e) {
    throw e;
  }(new Error("Unsupported encoding"));
}
;

你也可以在 Babel Playground 自行玩耍~

Object.groupBy & Map.groupBy

TypeScript 5.4 版本新增了 Object.groupByMap.groupBy 方法的类型声明,这两个方法来自于 proposal-array-grouping 提案,其已进入 Stage 4,将成为 ECMAScript 的一部分。

这两个方法其实类似于 Lodash 中的 groupBy,但不同点在于,Object.groupByMap.groupBy 分别会将结果存储为 Object 与 Map 的形式:

const array = [1, 2, 3, 4, 5];

Object.groupBy(array, (num, index) => {
  return num % 2 === 0 ? 'even': 'odd';
});
// =>  { odd: [1, 3, 5], even: [2, 4] }

// using an object key.
const odd  = { odd: true };
const even = { even: true };
Map.groupBy(array, (num, index) => {
  return num % 2 === 0 ? even: odd;
});
// =>  Map { {odd: true}: [1, 3, 5], {even: true}: [2, 4] }

条件类型判断优化

type IsArray<T> = T extends any[] ? true : false;

function f1<U extends object>(x: IsArray<U>) {
    let t: true = x;   // Error: Type 'IsArray<U>' is not assignable to type 'true'.
    let f: false = x;  // No Error
}

在这个例子的函数 f1 内部,由于此时暂时没有足够的类型信息,无法知晓 U 可能的类型,TypeScript 会使用 U 的约束 object 来进行类型分析,而 object extends any[] 并不成立,因此上面的例子里此前 TypeScript 分析出的 x 的类型是 false 字面量类型。

但实际上,只使用约束来判断条件类型是不那么准确的,比如,U 可以是 any[] 类型,any[] extends any[] 成立,此时 IsArray 的返回值是 true,也可以是 {} 类型,{} extends any[] 不成立,此时返回值是 false,有点像条件类型中的判断条件使用 any extends 一样,此时它包含了让条件成立的一部分,以及让条件不成立的一部分,最终结果是条件类型两个分支组成的联合类型:

type Result1 = any extends 'linbudu' ? 1 : 2; // 1 | 2
type Result2 = any extends string ? 1 : 2; // 1 | 2
type Result3 = any extends {} ? 1 : 2; // 1 | 2
type Result4 = any extends never ? 1 : 2; // 1 | 2

在 5.4 版本,TypeScript 修正了这一判断行为,现在它能够分析出这种「可能成立也可能不成立」的情况了,因此在一开始的例子里 x 会被推导为 boolean 类型。同时,对于必定成立、必定不成立的类型推导仍然能够正常工作:

type IsArray<T> = T extends number ? true : false;

function f1<U extends string>(x: IsArray<U>) {
  let t: true = x; // 不能将类型“IsArray<U>”分配给类型“true”。不能将类型“false”分配给类型“true”。
  let f: false = x;
}

function f2<U extends number>(x: IsArray<U>) {
  let t: true = x; // 不能将类型“IsArray<U>”分配给类型“false”。 不能将类型“true”分配给类型“false”。
  let f: false = x; // Error, but previously wasn't
}

其它变更

即将移除的配置

TypeScript 的废弃策略是 5 个 minor 版本的渐进式,如 compilerOptions.out 将在 5.5 版本中被移除,那么从 5.0 版本开始,只要使用了此配置就会得到一个错误,需要显式设置 ignoreDeprecations: "5.0"

类似的,suppressImplicitAnyIndexErrorsnoStrictGenericChecks 等配置将在 5.5 版本中被正式移除,可以参考 Upcoming Changes from TypeScript 5.0 Deprecations 来了解所有相关配置。

本次的 Devblog 解析就到这里了,其实还有一些如 Support for require() calls in --moduleResolution bundler and --module preserveIsolated Declarations 这样的功能没有介绍到,原因则是因为它们的牵涉较广,需要大量的前置知识铺垫,同时也不太会被业务开发者直接感知到,这里就先跳过了,如果你有兴趣,请关注笔者的专栏,后续可能会在专栏更新世界观级别的介绍也说不定(画饼)。

全文完,我们 TypeScript 5.5 beta 见 :-)