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

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

bash 复制代码
$ npm install typescript@beta

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

本篇是笔者的第十篇 TypeScript 更新日志,上一篇是 「TypeScript 5.3 beta:Import Attributes 提案、Throw 表达式、类型收窄优化」,你可以在此账号的创作中找到(或在掘金/知乎/Twitter搜索林不渡),接下来笔者也将持续更新 TypeScript 的 DevBlog 相关,感谢你的阅读。

NoInfer 工具类型

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

来看实际的例子:

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 把第二个参数推导出的类型也塞进了泛型类型里。

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

typescript 复制代码
class Animal {
  move;
}
class Dog extends Animal {
  woof;
}

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

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

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

typescript 复制代码
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:

typescript 复制代码
function checkLevel<T extends string, K extends T>(
  level: T[],
  defaultLevel?: K
): T { }

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

typescript 复制代码
function checkLevel<T extends string>(level: T[], defaultLevel?: NoInfer<T>) {}

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

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

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

typescript 复制代码
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 试试:

typescript 复制代码
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 表现:

ts 复制代码
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进行了优化。

先来看一个简单的例子:

typescript 复制代码
function f1() {
  let x: string | number;

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

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

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

typescript 复制代码
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 进行了优化,现在它能够正确分析出这个变量最后一次赋值以后的类型------此时变量的类型不会再发生改变了,即闭包捕获的值已经固定,在上面的例子中就是这样的:

typescript 复制代码
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
  });
}

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

typescript 复制代码
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 并不能知道这个赋值的嵌套函数,是在什么时候调用的,因此也就无法确定真正的最后一次赋值。

看一个更接地气的例子:

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 类型。

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

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

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

typescript 复制代码
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 语句,举例来说,此前我们写一个对黑名单用户抛出错误的逻辑可能是这样的:

javascript 复制代码
const safeUser = isSafeUser();

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

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

javascript 复制代码
isSafeUser() ? void 0 : throw new Error('...');

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

javascript 复制代码
const user = isSafeUser() || throw new Error('...');

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

javascript 复制代码
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 ,比如上面的例子的编译结果大致是这样的:

javascript 复制代码
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 的形式:

typescript 复制代码
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] }

条件类型判断优化

typescript 复制代码
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 一样,此时它包含了让条件成立的一部分,以及让条件不成立的一部分,最终结果是条件类型两个分支组成的联合类型:

typescript 复制代码
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 类型。同时,对于必定成立、必定不成立的类型推导仍然能够正常工作:

typescript 复制代码
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 见 :-)

相关推荐
careybobo2 小时前
海康摄像头通过Web插件进行预览播放和控制
前端
TDengine (老段)2 小时前
TDengine 中的关联查询
大数据·javascript·网络·物联网·时序数据库·tdengine·iotdb
杉之3 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
喝拿铁写前端3 小时前
字段聚类,到底有什么用?——从系统混乱到结构认知的第一步
前端
再学一点就睡3 小时前
大文件上传之切片上传以及开发全流程之前端篇
前端·javascript
木木黄木木4 小时前
html5炫酷图片悬停效果实现详解
前端·html·html5
请来次降维打击!!!5 小时前
优选算法系列(5.位运算)
java·前端·c++·算法
難釋懷5 小时前
JavaScript基础-移动端常见特效
开发语言·前端·javascript
还是鼠鼠5 小时前
Node.js全局生效的中间件
javascript·vscode·中间件·node.js·json·express
自动花钱机6 小时前
WebUI问题总结
前端·javascript·bootstrap·css3·html5