最近面试时被提问到 TypeScript 相关的知识,其中一个问题就是:什么时候应该在 TypeScript 中使用 never
和 unknown
。
笔者觉得自己没答好,于是再次学习了相关知识分享下来。本文不是 TypeScript types 的科普,所以别着急,只需一步一个脚印前进即可。
首先,我们要明确什么是 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)。
来点代码,再谈其他:
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
的类型来推导最终的返回值类型:sum
是 number
类型。
我不确定大家工作中是否要写这些类型定义,但这就是 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
类型的变量,因此我们可以在考虑使用 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。
好了,下次见。