ts 类型体操之内置工具类型(下)

本文是 ts 内置工具最后一篇。拖拖拉拉总算要把所有的内置工具讲完了。

NonNullable<Type>:从类型 T 中 剔除 null 和 undefined

NonNullable 是一个比较简单的工具类型,它接受一个范型 T 作为参数,如果 T 是 nullundefined,则返回 never,否则返回 T 本身。NonNullable 早些年的实现如下所示:

type NonNullable<T> = T extends null | undefined ? never : T;

不记得 extends 关键字的可以回顾一下《ts 类型体操之内置工具类型(上)》的内容。extends 实际执行时是对联合类型T里的每一个元素分别进行条件判断。所以 NonNullable 通常也是用于联合类型操作,剔除联合类型中的 null 和 undefined:

type T0 = NonNullable<string | number | undefined>; // string | number
type T1 = NonNullable<string[] | null | undefined>; // string[]

不过在typescript 4.8后,NonNullable 被重写了,现在它的实现如下:

type NonNullable<T> = T & {};

这个实现其实更简单,它利用了类型系统中的交叉(&)操作符,将 T 和一个空对象类型 {} 进行合并,从而剔除了 T 中的 nullundefined。这里提几个八股小知识点: {} 是除了 undefined 和 null 之外,所有类型的父类型。 所以 {} 和 undefined 或 null 的交叉类型是 never,而且其余的类型和{}交叉的结果是其本身。

NonNullable<number | undefined>为例:

NonNullable<number | undefined>
=> (number | undefined) & {} => number
=> (number & {} ) | (undefined & {})
=> number | never
=> number

再补充一个八股 unknown 事实上等价于 {} | undefined | null, 所以 NonNullable<unknown> 等于 {},但是 NonNullable<any> 等于 any

Awaited<T>

Awaited 类型用于获取 Promise 的返回值类型。例如:

type T0 = Awaited<Promise<string>>; // string
type T1 = Awaited<Promise<Promise<number>>>; // number
type T2 = Awaited<boolean | Promise<number>>; // number | boolean

Awaited “方法”还是有点难度的:

  • 该类型需要支持递归:它需要将嵌套的 Promise 的类型展开,直至得到 Promise 的最终返回值类型。
  • 递归的结束条件是:对非 PromiseLike 的类型(没有 then 方法的对象类型)返回 never。

如下是 Awaited 的原始版本:

/**
 * Recursively unwraps the "awaited type" of a type. Non-promise "thenables" should resolve to `never`. This emulates the behavior of `await`.
 */
type Awaited<T> = T extends null | undefined
  ? T // special case for `null | undefined` when not in `--strictNullChecks` mode
  : T extends object & { then(onfulfilled: infer F, ...args: infer _): any } // `await` only unwraps object types with a callable `then`. Non-object types are not unwrapped
    ? F extends (value: infer V, ...args: infer _) => any // if the argument to `then` is callable, extracts the first argument
      ? Awaited<V> // recursively unwrap the value
      : never // the argument to `then` was not callable
    : T; // non-object or non-thenable

我们逐行解释上面的实现:

  • T extends null | undefined:如果 T 是 null 或者 undefined,则直接返回 T。这个判断是为了处理非严格模式下,null 和 undefined 的情况。在严格模式下,null 和 undefined 不能作为合法的 Promise。
  • T extends object & { then(onfulfilled: infer F, ...args: infer _): any }:这行很长,中心思想是:如果 T 是一个对象,并且该对象具有 then 方法,那么我们就可以认为它是一个 PromiseLike 类型。这里我们用到了infer关键字,它表示在类型推导过程中,将 then 方法的第一个参数类型提取出来,赋值给 F。若 T 不是 PromiseLike 类型,则直接返回 T。
  • F extends (value: infer V, ...args: infer _) => any:F由上一步推断得到,如果 then 方法的第一个参数是函数类型,那么我们就可以认为它是一个 Promise。我们再次用到了infer关键字,将 then 方法的第一个参数的类型提取出来,赋值给 V。若 F 不是函数类型,则不是一个合法的 Promise,直接返回 never。
  • Awaited<V>:递归地展开 V,直到 V 不再是 PromiseLike 类型为止。

原始版本虽然能看得懂,但是太麻烦了。我们自实现type challenge这道 MyAwaited 的时候可以用下面一个简化版代替:

type Awaited<T> = T extends PromiseLike<infer R> ? Awaited<R> : T;
  • PromiseLike<T>也是一个内置接口,表示一个具有 then 方法的对象类型——Promise 的鸭子类型。大家可以直接用。完整的定义如下,有兴趣的朋友可以看一下:

    interface PromiseLike<T> {
      /**
       * Attaches callbacks for the resolution and/or rejection of the Promise.
       * @param onfulfilled The callback to execute when the Promise is resolved.
       * @param onrejected The callback to execute when the Promise is rejected.
       * @returns A Promise for the completion of which ever callback is executed.
       */
      then<TResult1 = T, TResult2 = never>(
        onfulfilled?:
          | ((value: T) => TResult1 | PromiseLike<TResult1>)
          | undefined
          | null,
        onrejected?:
          | ((reason: any) => TResult2 | PromiseLike<TResult2>)
          | undefined
          | null,
      ): PromiseLike<TResult1 | TResult2>;
    }
    
  • PromiseLike<infer R>:表示将 PromiseLike 类型中的泛型参数 R 提取出来,然后递归调用 Awaited,直到递归到非 PromiseLike 的类型。这里有个知识点:infer 甚至可以推断出接口中的范型参数。比如Promise<string>,可以直接推断出 string

  • extends ? (...) : T: 我们之前提到过:extends 会遍历联合类型。对于boolean | Promise<number>这样的 case,extends 会分别对booleanPromise<number>进行判断,最终返回 boolean | number。

NoInfer<Type>

NoInfer<Type>:用于防止 TypeScript 从泛型函数内部推断类型。它是一个固有类型,没有更底层的实现:

// lib.es5.d.ts
type NoInfer<T> = intrinsic;

它是 TypeScript 5.4 刚推出的一个内置类型,所以我们正好看看如何在某些情况下使用它来改进 TypeScript 的推理行为。如下例所示:通常的情况下编译器是可以从函数入惨里推断出 result 类型是 'hello'

const returnWhatIPassedIn = <T>(value: T) => value;

const result = returnWhatIPassedIn('hello'); //const result: 'hello'

但如果我们用 NoInfer<T> 来包装 value, NoInfer 使 value 无法成为有效推断来源 T。因此如下 result 被推断为 unknown。

const returnWhatIPassedIn = <T>(value: NoInfer<T>) => value;

const result = returnWhatIPassedIn('hello'); //const result: unknown

我们需要明确提供范型才能获得 returnWhatIPassedIn 的返回类型:

const result = returnWhatIPassedIn<'hello'>('hello');
// const result: "hello"

NoInfer 要解决什么问题呢?一个很好的例子是创建有限状态机 (FSM) 的函数。FSM 有一个 initial 状态和一个列表 states。initial 状态必须是 states 之一。

declare function createFSM<TState extends string>(config: {
  initial: TState;
  states: TState[];
}): TState;

请注意,TypeScript 可以从两个可能的地方推断类型:initial 和 states。如下所示:example 的类型推断为"not-allowed" | "open" | "closed"。显然,正确的类型推断应该是状态机只有 "open" | "closed" 这两种类型,而 initial = "not-allowed" 要抛错。

const example = createFSM({
  initial: 'not-allowed',
  states: ['open', 'closed'],
});
// const example: "not-allowed" | "open" | "closed"

怎么用 NoInfer 改进呢?

declare function createFSM<TState extends string>(config: {
  initial: NoInfer<TState>;
  states: TState[];
}): TState;

现在,当我们调用时 createFSM 时,TypeScript 将仅从 states 推断 TState类型;并给 initial 的赋值抛出一个类型检查错误 —— Type '"not-allowed"' is not assignable to type '"open" | "closed"'

createFSM({
  initial: 'not-allowed', // Type '"not-allowed"' is not assignable to type '"open" | "closed"'.
  states: ['open', 'closed'],
});

我们使用 NoInfer 控制 TypeScript 从泛型函数内部推断类型的位置。当有多个运行时参数,每个参数都引用相同的类型参数时,这会很有用。

Intrinsic String Manipulation Types (字符串操作类型)

最后,我们再列一下另外四个固有的字符串操作类型:

  • Uppercase<S>:将字符串中的每个字符转换为大写。
  • Lowercase<S>:将字符串中的每个字符转换为小写。
  • Capitalize<S>:将字符串中的第一个字符转换为大写。
  • Uncapitalize<S>:将字符串中的第一个字符转换为小写。

效果如下:

type Greeting = 'Hello, world';
type ShoutyGreeting = Uppercase<Greeting>; // "HELLO, WORLD"
type LowercaseGreeting = Lowercase<Greeting>; // "hello, world"
type CapitalizedGreeting = Capitalize<Greeting>; // "Hello, world"
type UncapitalizedGreeting = Uncapitalize<Greeting>; // "hello, world"

这些方法在 type challenge 里倒是挺常用的,比如这道

把驼峰类型的字符串转换成烤串类型的字符串

type FooBarBaz = KebabCase<'FooBarBaz'>;
const foobarbaz: FooBarBaz = 'foo-bar-baz';

type DoNothing = KebabCase<'do-nothing'>;
const doNothing: DoNothing = 'do-nothing';

实现如下:

type KebabCase<S extends string> = S extends `${infer F}${infer R}`
  ? R extends Uncapitalize<R>
    ? `${Uncapitalize<F>}${KebabCase<R>}`
    : `${Uncapitalize<F>}-${Uncapitalize<KebabCase<R>>}`
  : S;

我们再逐行解释一下上面的实现:

  1. S extends `${infer F}${infer R}`:我们使用模板字符串类型来拆分S,将字符串 S 分解为第一个字符 F 和剩余部分 R
  2. R extends Uncapitalize<R>:我们检查剩余部分 R 是否是小写开头:如果是,我们直接将第一个字符 F 转换为小写,并递归调用 KebabCase 处理剩余部分 R;如果不是,我们也将第一个字符 F 转换为小写,并在后面添加一个连字符 -。然后递归调用 KebabCase 处理剩余部分 R
  3. S:如果字符串 S 已经是空字符串——S extends ...判否,我们直接返回它本身。

通过这种方式,我们完成了这道中等难度的题目。

小结

本文是《内置工具类型》系列最后一篇,一共 22 个内置的工具类型。这些工具类型本质是类型的 utils 方法,帮助我们写出更加健壮的代码类型。希望大家能熟练掌握这些工具类型,并在实际工作中灵活运用。之后的篇幅就要进入《类型体操》真题演练了,敬请期待。

题外话

最近,我在看一些国内程序员的论坛,高赞的文章很多是关于“下岗再就业”的。下个月,我的合同即将到期,很可能也要直面人生了。很羡慕我们厂里的一个美国老大爷,快 60 岁了,每天写两小时代码,依旧延续着自己的职业生涯。真心希望国内的程序员们,也能像他一样,开开心心地工作到退休。

文章同步发布于an-Onion 的 Github。码字不易,欢迎点赞。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,753评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,668评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,090评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,010评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,054评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,806评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,484评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,380评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,873评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,021评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,158评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,838评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,499评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,044评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,159评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,449评论 3 374
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,136评论 2 356

推荐阅读更多精彩内容