TS 爱好者请查收:TS 5.5 公测版官宣!(上)

发布于:2024-05-09 ⋅ 阅读:(27) ⋅ 点赞:(0)

给前端以福利,给编程以复利。大家好,我是大家的林语冰。

今年三月份 TS 团队刚刚官宣了 2024 的第一个次版本升级 TS 5.4(稳定版),仅一个半月后,TS 团队再次发朋友圈:TS 5.5 Beta(公测版)发布

趴地一下很快啊,会 TS 和不会 TS 的道友都给整“蕉绿”了......本期一起来预习一下 TS 5.5 的官方博客,翻译得不是很好,因为我也看得不是很懂。

ts-beta.png

免责声明

本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 。

推断类型谓词

变量类型在代码中移动时,TS 的控制流分析在跟踪其变化方面干得漂亮:

interface Bird {
  commonName: string
  scientificName: string
  sing(): void
}
// 国家映射到国鸟:country names -> national bird.
// 并非所有国家都有官方鸟类
// (说得就是你,加拿大!)
declare const nationalBirds: Map<string, Bird>

function makeNationalBirdCall(country: string) {
  // bird 具有 Bird | undefined 声明类型
  const bird = nationalBirds.get(country)

  if (bird) {
    // if 语句中,bird 具有 Bird 类型
    bird.sing()
  } else {
    // 这里 bird 则具有 undefined 类型
  }
}

通过让你处理 undefined 的情况,TS 会促使你编写更鲁棒的代码。

在过去,这种类型细化很难应用到数组。在之前所有版本的 TS 中,这都是一个错误:

function makeBirdCalls(countries: string[]) {
  // birds: (Bird | undefined)[]
  const birds = countries
    .map(country => nationalBirds.get(country))
    .filter(bird => bird !== undefined)

  for (const bird of birds) {
    // 报错:bird 可能是 undefined
    bird.sing()
  }
}

这段代码简直棒棒哒:我们已经过滤了列表中所有 undefined 的值。但 TS 却未能理解。

TS 5.5 中,类型检查器可以妥当处理以下代码:

function makeBirdCalls(countries: string[]) {
  // birds: Bird[]
  const birds = countries
    .map(country => nationalBirds.get(country))
    .filter(bird => bird !== undefined)

  for (const bird of birds) {
    bird.sing() // OK!
  }
}

请注意 birds 拥有更精确的类型。

这行得通,因为 TS 现在可以推断 filter 函数的类型谓词。通过将其抽取到单独的函数中,你可以清楚看到其工作过程:

// function isBirdReal(bird: Bird | undefined): bird is Bird
function isBirdReal(bird: Bird | undefined) {
  return bird !== undefined
}

bird is Bird 是类型谓词。这意味着,若函数返回 true,则它就是 Bird;若函数返回 false,则它就是 undefined

Array.prototype.filter 的类型声明了解类型谓词,因此最终结果是你获得了更精确的类型,且代码通过了类型检查器。

如果满足以下条件,TS 将推断函数返回类型谓词:

  1. 该函数没有显式返回类型或类型谓词注解。
  2. 该函数有且仅有一个 return 语句,且没有隐式返回。
  3. 该函数不会改变其参数。
  4. 该函数返回一个与参数细化相关的 boolean 表达式。

一般而言,这能如期工作。以下是推断类型谓词的更多示例:

// const isNumber: (x: unknown) => x is number
const isNumber = (x: unknown) => typeof x === 'number'

// const isNonNullish: <T>(x: T) => x is NonNullable<T>
const isNonNullish = <T>(x: T) => x != null

以前,TS 只会推断这些函数返回 boolean。现在 TS 使用 x is numberx is NonNullable<T> 等类型谓词来推断签名。

类型谓词具有“当且仅当”的语义。如果函数返回 x is T,则意味着:

  1. 若函数返回 true,则 x 具有类型 T
  2. 若函数返回 false,则 x 不具有类型 T

如果你期望推断类型谓词,但事实并非如此,那么你可能违反了第二条规则。这通常会带来“truthiness”(真值)检查:

function getClassroomAverage(
  students: string[],
  allScores: Map<string, number>
) {
  const studentScores = students
    .map(student => allScores.get(student))
    .filter(score => !!score)

  return studentScores.reduce((a, b) => a + b) / studentScores.length
  // 报错: Object 可能是 undefined。
}

TS 没有推断出 score => !!score 的类型谓词,这是正确的:若返回 true,则 score 就是 number;若返回 false,则 score 可以是 undefinednumber(具体来说就是 0)。

这是一个真正的 bug:如果任何学生在测试中得零分,那么过滤掉其分数将使平均值向上倾斜。超过平均水平的人少了,且悲伤的人多了!

与第一个示例一样,最好显式过滤掉 undefined 值:

function getClassroomAverage(
  students: string[],
  allScores: Map<string, number>
) {
  const studentScores = students
    .map(student => allScores.get(student))
    .filter(score => score !== undefined)

  return studentScores.reduce((a, b) => a + b) / studentScores.length // ok!
}

真值检查将推断对象类型的类型谓词,其中不存在歧义。

粉丝请记住,函数必须返回 boolean 作为推断类型谓词的候选:x => !!x 可能推断类型谓词,但 x => x 绝对不会。

显式类型谓词一如既往地有效。TS 不会检查它是否会推断出相同的类型谓词。显式类型谓词(“is”)并不比类型断言(“as”)更安全。

如果 TS 现在推断出比你想要的更精确的类型,则此功能可能会破坏现有代码。举个栗子:

// 以前,nums: (number | null)[]
// 现在,nums: number[]
const nums = [1, 2, 3, null, 5].filter(x => x !== null)
// TS 5.4 中 OK,TS 5.5 中报错
nums.push(null)

解决方法是使用显式类型注解告诉 TS 你期望的类型:

const nums: (number | null)[] = [1, 2, 3, null, 5].filter(x => x !== null)
nums.push(null)
// 在所有版本中都 OK

常量索引访问的控制流缩窄

objkey 都是常量时,TS 现在能够缩窄 obj[key] 形式的表达式。

function f1(obj: Record<string, unknown>, key: string) {
  if (typeof obj[key] === 'string') {
    // 现在有效,以前这会报错
    obj[key].toUpperCase()
  }
}

在上述代码中,objkey 都没有变更,因此在 typeof 检查后,TS 可以将 obj[key] 的类型缩窄为 string

JSDoc 中的类型导入

如今,如果你只想导入某些东东在 JS 文件中进行类型检查,那就头大了。

如果运行时不存在名为 SomeType 的类型,JS 开发者就不能直接导入该类型。

// ./some-module.d.ts
export interface SomeType {
  // ...
}

// ./index.js
import { SomeType } from './some-module'
// 运行时错误!

/**
 * @param {SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

SomeType 在运行时不存在,因此导入会失败。开发者可以改用命名空间导入。

import * as someModule from './some-module'

/**
 * @param {someModule.SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

但是 ./some-module 仍然在运行时导入,这也可能是不可取的。

为了避免这种情况,开发者通常必须在 JSDoc 注释中使用 import(...) 类型。

/**
 * @param {import("./some-module").SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

如果你想在多个位置复用同一类型,可以使用 typedef 避免重复导入。

/**
 * @typedef {import("./some-module").SomeType} SomeType
 */

/**
 * @param {SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

这有助于 SomeType 的局部使用,但对于许多导入而言,它会重复且可能有点冗长。

这就是为什么 TS 现在支持新的 @import 注释标记,该标记的语法与 ECMAScript 导入相同。

/** @import { SomeType } from "some-module" */

/**
 * @param {SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

在这里,我们使用命名导入。我们还可以将导入编写为命名空间导入。

/** @import * as someModule from "some-module" */

/**
 * @param {someModule.SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

因为这些只是 JSDoc 的注释,所以它们根本不会影响运行时行为。

正则表达式语法检查

目前为止,TS 通常会跳过代码中的大多数正则表达式。这是因为正则表达式在技术上具有可扩展的语法,而 TS 从未尝试将正则表达式编译为早期版本的 JS。

尽管如此,这意味着许多常见问题在正则表达式中不会被发现,它们要么在运行时报错,要么静默失败。

但 TS 现在会对正则表达式进行基本语法检查!

let myRegex = /@robot(\s+(please|immediately)))? do some task/
// 报错!意外的 “)”。
// 你是想用反斜杠转义吗?

这是一个简单示例,但是这种检查可以发现很多常见错误。事实上,TS 的检查稍微超出了语法检查的范畴。

举个栗子,TS 现在可以捕获不存在的反向引用问题。

let myRegex = /@typedef \{import\((.+)\)\.([a-zA-Z_]+)\} \3/u
// 报错!
// 此反向引用引用的分组不存在。
// 此正则表达式中有且仅有 2 个捕获组。

这同样适用于命名捕获组。

let myRegex =
  /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<namedImport>/
// 报错!
// 此正则表达式中没有名为“namedImport”的捕获组。

当比 ECMAScript 的目标版本更新时,TS 的检查现在还可以识别何时使用某些 RegExp 功能。

举个栗子,如果我们在 ES5 目标中使用上述的命名捕获组,我们将收到错误。

let myRegex =
  /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<importedEntity>/
// 报错!
// 命名捕获组只能在“ES2018”或更高版本的目标中可用。

对于某些正则表达式标志也是如此。

粉丝请注意,TS 的正则表达式支持仅限于正则表达式字面量。如果你尝试使用字符串字面量调用 new RegExp,TS 将不会检查提供的字符串。

独立声明

声明文件又名 .d.ts 文件`,向 TS 描述现有库和模块的形状。这种轻量级描述包括库的类型签名,但不包括函数体等实现细节。

发布声明文件是为了让 TS 可以有效地检查你对库的使用情况,而无需分析库本身。虽然可以手写声明文件,但如果你正在编写类型化代码,那么让 TS 使用 --declaration 从源文件自动生成会更简单和安全。

TS 编译器及其 API 一直负责生成声明文件;但是,在某些用例中,你可能需要使用其他工具,或者传统的构建过程无法扩展。

用例:更快的声明发射工具

请想象一下,如果你想创建一个更快的工具来生成声明文件,也许作为发布服务或新打包程序的一部分。虽然存在一个元气满满的生态系统,其中包含可以将 TS 转换为 JS 的快速工具,但将 TS 转换为声明文件的情况却并非如此。

原因在于 TS 的推理允许我们在不显式声明类型的情况下编写代码,这意味着,声明发射可能十分复杂。

让我们考虑一个添加两个导入变量的函数的简单示例。

// util.ts
export let one = '1'
export let one = '2'

// add.ts
import { one, two } from './util'
export function add() {
  return one + two
}

即使我们唯一想做的就是生成 add.d.ts,TS 也需要爬入另一个导入的文件 util.ts,推断 onetwo 的类型是字符串,然后计算两个字符串上的 + 运算符将导致 string 返回类型。

// add.d.ts
export declare function add(): string

虽然这种推理对于开发体验至关重要,但这意味着想要生成声明文件的工具需要拷贝类型检查器的部分内容,包括推理和解析模块说明符,来遵循导入的能力。

用例:并行声明发射和并行检查

请想象一下,如果你有一个包含许多项目和一个多核 CPU 的 monorepo,只是希望它可以辅助你更快检查代码。如果我们可以通过在不同的核心上运行每个项目来同时检查所有这些项目,那不是很棒棒吗?

不幸的是,我们没有并行完成所有工作的自由。原因是我们必须按依赖顺序构建这些项目,因为每个项目都会检查其依赖项的声明文件。所以我们必须首先构建依赖关系来生成声明文件。TS 的项目引用功能工作方式相同,以“拓扑”依赖顺序构建项目集。

举个栗子,如果我们有两个名为 backendfrontend 的项目,并且它们都依赖 core 项目,则 TS 无法启动类型检查 frontendbackend,直到 core 已构建并已生成其声明文件。

01-core.png

在上图中,你可以看到我们遭遇了瓶颈。虽然我们可以并行构建 frontendbackend,但我们需要首先等待 core 完成构建,然后才能开始。

我们如何改进这一点?好吧,如果一个快速工具可以并行生成 core 的所有声明文件,那么 TS 就可以立即通过类型检查 corefrontendbackend 也是并行的。

解决方案:显式类型!

这两个用例的共同需求是,我们需要一个跨文件类型检查器来生成声明文件。这对工具社区而言有大量需求。

作为一个更复杂的示例,如果我们想要以下代码的声明文件......

import { add } from './add'

const x = add()

export function foo() {
  return x
}

我们需要为 foo 生成签名。那么这需要查看 foo 的实现。foo 只返回 x,因此获取 x 的类型需要查看 add 的实现。但这可能需要查看 add 依赖项的实现等。

我们在这里看到的是,生成声明文件需要大量逻辑来找出不同位置的类型,这些位置甚至可能不是当前文件的本地类型。

尽管如此,对于寻求快速迭代时间和完全并行构建的开发者而言,还有另一种思考这个问题的方法。声明文件只需要模块的公共 API 的类型,换而言之,就是导出的内容的类型。

争议在于,如果开发者愿意显式写出其导出内容的类型,那么工具就可以生成声明文件,而无需查看模块的实现,也无需重新实现完整的类型检查器。

这就是 --isolatedDeclarations 新选项的用武之地。当模块在没有类型检查器的情况下无法可靠地转换时,--isolatedDeclarations 会报错。

简而言之,如果你的文件在导出时没有充分注释,它会让 TS 报错。

这意味着在上述示例中,我们会看到如下错误:

export function foo() {
  // 报错!
  // 当启动 --isolatedDeclarations 时,
  // 函数具有显式的返回类型注解
  return x
}

为什么错误是可取的?

因为这意味着,TS 可以:

  1. 预先告诉我们其他工具在生成声明文件时是否会出现问题
  2. 提供快速修复来辅助添加这些缺失的注释

不过,这种模式并不需要到处注解。对于本地开发而言,这些可以忽略,因为它们不会影响公共 API。

举个栗子,以下代码不会报错:

import { add } from './add'

const x = add('1', '2')
// x 不会报错,它没有被导出。

export function foo(): string {
  return x
}

还有某些表达式的类型计算起来“微不足道”。

// x 不会报错。
// 计算类型是“数字”很简单。
export let x = 10

// y 不会报错。
// 我们可以从返回表达式中获取类型。
export function y() {
  return 20
}

// z 不会报错。
// 类型断言明确了类型是什么。
export function z() {
  return Math.max(x, y()) as number
}

使用 isolatedDeclarations

isolatedDeclarations 要求还设置 declarationcomposite 标志。

粉丝请注意,isolatedDeclarations 不会改变 TS 执行发射的方式,只是改变它报错的方式。重要的是,与 isolatedModules 类似,在 TS 中启用该功能不会立即带来这里讨论的潜在福利。

因此,请耐心等待该领域未来的发展。牢记工具作者,我们还应该认识到,如今,并非所有 TS 发射的声明都可以被其他想要使用它作为指南的工具轻松复制。这是我们正在积极努力改进的事情。

我们还认为值得指出的是,应根据具体情况采用 isolatedDeclarations。使用 isolatedDeclarations 时会丢失一些开发者的人体工程学设计,因此,如果你的设置没有利用前面提到的两种方案,那么它可能不是正确的选择。

对于其他人而言,isolatedDeclarations 的工作已经发现了许多优化和解锁不同并行构建策略的机会。同时,如果你愿意做出权衡,我们相信一旦外部工具可用,isolatedDeclarations 就可以成为加快构建过程的强大工具。

配置文件的 ${configDir} 模板变量

在许多代码库中,重用共享 tsconfig.json 文件作为其他配置文件的“base”十分常见。这通过 tsconfig.json 文件中的 extends 字段来实现。

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist"
  }
}

问题之一在于,tsconfig.json 文件中的所有路径都相对于文件本身的位置。这意味着,如果你有一个由多个项目使用的共享 tsconfig.base.json 文件,则相对路径通常在派生项目中没有用处。

举个栗子,假设存在以下的 tsconfig.base.json

{
    "compilerOptions": {
        "typeRoots": [
            "./node_modules/@types"
            "./custom-types"
        ],
        "outDir": "dist"
    }
}

如果作者的意图是扩展此文件的每个 tsconfig.json 都应该:

  1. 输出到相对于派生 tsconfig.jsondist 目录
  2. 并且有一个相对于派生 tsconfig.jsoncustom-types 目录

那么这行不通。typeRoots 路径将相对于共享 tsconfig.base.json 文件的位置,而不是相对于扩展它的项目。扩展此共享文件的每个项目都需要声明其自己的具有相同内容的 outDirtypeRoots

这可能会令人头大,且很难在项目之间保持同步,虽然上述示例使用 typeRoots,但这是 paths 和其他选项的常见问题。

为了解决此问题,TS 5.5 引入了一个新的模板变量 ${configDir}。当 ${configDir} 写入 tsconfig.jsonjsconfig.json 文件的某些路径字段时,此变量将替换为给定编译中配置文件的包含目录。这意味着上面的 tsconfig.base.json 可以重写为:

{
    "compilerOptions": {
        "typeRoots": [
            "${configDir}/node_modules/@types"
            "${configDir}/custom-types"
        ],
        "outDir": "${configDir}/dist"
    }
}

现在,当项目扩展此文件时,路径将相对于派生的 tsconfig.json,而不是共享的 tsconfig.base.json 文件。这使得跨项目共享配置文件更加容易,并确保配置文件更加可移植。

如果你打算使 tsconfig.json 文件可扩展,请考虑是否应使用 ${configDir} 编写 ./

参考文献

  1. TypeScript
  2. Blog
  3. GitHub

粉丝互动

本期话题是:如何评价 TS 5.5 公测版?你可以在本文下方自由言论,文明科普。

欢迎持续关注“前端俱乐部”,给前端以福利,给编程以复利。

坚持阅读的小伙伴可以给自己点赞!谢谢大家的点赞,掰掰~

26-cat.gif