TypeScript 中的 never、unknown 的使用时机

2024年09月07上次更新于 大约 2 个月前
编程

最近面试时被提问到 TypeScript 相关的知识,其中一个问题就是:什么时候应该在 TypeScript 中使用 neverunknown

笔者觉得自己没答好,于是再次学习了相关知识分享下来。本文不是 TypeScript types 的科普,所以别着急,只需一步一个脚印前进即可。

TypeScript 类型浅谈

首先,我们要明确什么是 TypeScript 的类型。

简单说,string类型即 TypeScript 中字符串变量的类型集合。同理,number是数字变量的类型集合,当我们写下如下代码时:

const stringOrNumber: strig | number = '1';

联合类型 string | number应运而生,这个联合类型就是字符串类型集合和数字类型集合的并集。

我们在 TypeScript 中可以把字符串类型或数字类型的变量赋值给 stringOrNumber,除此之外还能把什么类型的变量赋值给 stringOrNumber呢?

答案是:

  • never
  • any

😁 我们不谈 any类型,至少在离职之前不谈 any类型,也不在项目中使用 any类型(如果加班很多当我没说)。

never类型是一个空集,它处于类型系统的最内层。

当我们定一个变量的类型是 unknown时,我们实际上需要的就是一个未知类型的值,举个例子:

let foo: unknown;
foo = stringOrNumber; // ok

function demo(param: unknown) {
  // ...
  if(typeof param === 'string') {
    // ...
  }
}
demo(stringOrNumber); // ok

unknown是所有类型集合的超集,never则是所有类型的子集(bottom type)。

never 类型浅析

来点代码,再谈其他:

function asyncTimeout(ms: number): Promise<never> {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error(`Async Timeout: ${ms}`)), ms)
  })
}

上述代码是一个异步超时的函数,其返回值类型是 Promise<never>,接着我们继续实现一个支持超时的请求函数:

function fetchData(): Promise<{ price: number }> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ price: 100 });
    }, Math.random() * 5000);
  });
}

async function fetchPriceWithTimeout(tickerSymbol: string): Promise<number> {
  const stock = await Promise.race([
    fetchData(), // returns `Promise<{ price: number }>`
    asyncTimeout(3000), // returns `Promise<never>`
  ]);
  // stock is now of type `{ price: number }`
  return stock.price;
}

Promise.race 是 JavaScript 中 Promise 对象的一个静态方法,它用于处理多个 Promise 实例,并返回一个新的 Promise 实例。这个新的 Promise 实例会在第一个传入的 Promise 实例完成或拒绝时完成或拒绝。

fetchData是模拟的数据请求函数,实际开发中可以是某个接口请求,TypeScript 编译器将 race函数的返回值类型视为 Promise<{price: number}> | Promise<never>stock的类型为 {price: number},这就是 never类型的使用场景实例。

再来一个代码例子(来源于网络):

type Arguments<T> = T extends (...args: infer A) => any ? A : never
type Return<T> = T extends (...args: any[]) => infer R ? R : never

function time<F extends Function>(fn: F, ...args: Arguments<F>): Return<F> {
  console.time();
  const result = fn(...args);
  console.timeEnd();
  return result;
}

function add(a: number, b: number): number {
  return a + b;
}

const sum = time(add, 1, 2); // 调用 time 函数测量 add 函数的执行时间

console.log(sum); 
// 输出:
// [0.09ms] default
// 3

上述示例使用了 never类型来缩窄条件类型的结果。在 type Arguments<T>中,如果传入的泛型 T是一个函数,则将从其参数的类型生成一个推断类型 A作为满足条件时的类型。也就是说,如果我们传入的不是函数,那么TypeScript 编译器最后就会得到 never类型兜底。

于是乎在 time函数定义的时候就可以使用这两个带泛型的条件类型定义,最终让 time函数能够通过传入的函数 add的类型来推导最终的返回值类型:sumnumber类型。

我不确定大家工作中是否要写这些类型定义,但这就是 TypeScript。

我们可以在官方源代码中看到类似的类型声明:

/**
 * Exclude null and undefined from T
 */
type NonNullable<T> = T extends null | undefined ? never : T;

这里的条件类型对于传入泛型是联合类型的时候,会将判断类型分发到每一个到联合类型。

// if T = A | B

T extends U ? X : T == (A extends U ? X : A) | (B extends U ? X : B)

所以 NonNullable<string | null>的解析逐层如下:

NonNullable<string | null>
  // The conditional distributes over each branch in `string | null`.
  == (string extends null | undefined ? never : string) | (null extends null | undefined ? never : null)

  // The conditional in each union branch is resolved.
  == string | never

  // `never` factors out of the resulting union type.
  == string

最终编译器识别其为 string类型。

unknown 类型浅析

任何类型的值都可以赋值给 unknown类型的变量,因此我们可以在考虑使用 any的时候优先考虑 unknown,一旦我们选择使用 any,也就意味着我们主动关闭了 TypeScript的类型安全保护。

好了,下面来看一个代码示例:

function prettyPrint(x: unknown): string {
  if (Array.isArray(x)) {
    return "[" + x.map(prettyPrint).join(", ") + "]"
  }
  if (typeof x === "string") {
    return `"${x}"`
  }
  if (typeof x === "number") {
    return String(x)
  } 
  return "etc."
}

这是一个美观的打印输出函数,在调用时可以传入任意类型的参数。但是在传入后我们需要手动去缩窄类型,才能在逻辑内部使用特定类型变量的方法。

此外,如果数据来源不明确时,也可以促使开发者进行必要的类型验证和类型检查,减少运行时错误。

另外,笔者还见到了如下所示的类型守卫示例,稍微再分享一下:

function func(value: unknown) {
  // @ts-expect-error: Object is of type 'unknown'.
  value.test('abc');

  assertIsRegExp(value);

  // %inferred-type: RegExp
  value;

  value.test('abc'); // OK
}

/** An assertion function */
function assertIsRegExp(arg: unknown): asserts arg is RegExp {
  if (! (arg instanceof RegExp)) {
    throw new TypeError('Not a RegExp: ' + arg);
  }
}

对于未知类型的参数,可以使用 assertIsRegExp函数来断言类型,在其内部如果传入的参数符合类型的话,就不会抛出错误。在 func下半部分编译器则收到了断言信号,就可以直接使用 test方法了。

这种写法跟 if/else的区别在于开发者写 if/else时需要显式地书写类型不在预期时的代码,否则不满足类型时可能缺少运行信息,导致正确类型条件下的代码不执行。

我们没法确保开发者一定会写类型不在预期时的处理逻辑,因此使用类型断言守卫也是一种可行之策,在运行时将会报错,如果前端项目运行在有效的监控体系下,也能收到错误信息以便于排查。

如果你喜欢这种类型守卫,可以使用elierotenberg/typed-assert: A typesafe TS assertion library这样的库来减少工作量,它帮我们写了 assertIsRegExp这样的函数。

如果你喜欢显式的 if/else条件判断,也可以使用mesqueeb/is-what: JS type check (TypeScript supported) functions like `isPlainObject() isArray()` etc. A simple & small integration.这样的超轻量类型检查库。

如果你不想引入其他库,那么只能自己写类型判断代码了,但是自己写的话可要注意了,来看看如下代码:

// 1)
typeof NaN === 'number' // true
// 🤔 ("not a number" is a "number"...)

// 2)
isNaN('1') // false
// 🤔 the string '1' is not-"not a number"... so it's a number??

// 3)
isNaN('one') // true
// 🤔 'one' is NaN but `NaN === 'one'` is false...

这只是一个 NaN特例,但这就是 JavaScript

好了,下次见。

参考

not-by-ainot-by-ai
文章推荐

Friends

Jimmy老胡SubmaraBruce SongScarsu