2

高级 TypeScript:映射类型

 2 years ago
source link: https://www.fly63.com/article/detial/11877
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

更新日期: 2022-07-09阅读: 16标签: 类型分享

扫一扫分享

使用强类型语言会带来很多好处,TypeScript也不例外:你使用的类型越强,就能获得越好的结果。不幸的是,TypeScript 的灵活性让我们能够使用一种大得多的类型去描述某些对象,而这些对象原本可以使用更窄更有效的类型去建模。其中一个场景就是使用字符串和数字建模。

基本类型,例如 string 或 number,对于处理极大数据的数值是有意义的。但是,很多情形下,我们关心的只是有限个字符串(或其它基本类型)。我们当然可以在运行时去检测这种值是不是合法,但 TypeScript 也提供了一些机制,让我们能够更好地对这样的值建模。

本文,我们将以一个非常常见的需求为例,来展示 TypeScript 的某些不大常见的特性的应用。我们的例子是多地区多语言的站点。我们将展示 TypeScript 的如下特性:

  • as const 表达式
  • keyof 和 typeof
  • 泛型中的动态类型参数推断
  • 在联合中使用 never 过滤掉某些类型

假设我们正在开发一个适用于多个国家和地区的网站。每个国家都有自己版本的站点,并且提供多种不同的语言。同时,我们想要根据下面的配置,在某些地区禁用某些语言。

const EnabledRegons = {
  "GB": {
    "en": true,
  },
  "IT": {
    "en": true,
    "it": true,
  },
  "PL": {
    "pl": true,
    "en": false,
  },
  "LU": {
    "fr": true,
    "de": true,
    "en": true,
  }
} as const;

因为我们知道,这个配置是不会被修改的,因此,我们可以利用 as const 表达式,将其定义为只读的。

我们可以利用 TypeScript 的 playgroud 页面 ,看看这样的代码的 .d.ts 文件究竟是什么样子。

当没有 as const 表达式时,

TypeScript

.d.ts

const EnabledRegons = {
  "GB": {
    "en": true,
  },
  "IT": {
    "en": true,
    "it": true,
  },
  "PL": {
    "pl": true,
    "en": false,
  },
  "LU": {
    "fr": true,
    "de": true,
    "en": true,
  }
};
declare const EnabledRegons: {
    GB: {
        en: boolean;
    };
    IT: {
        en: boolean;
        it: boolean;
    };
    PL: {
        pl: boolean;
        en: boolean;
    };
    LU: {
        fr: boolean;
        de: boolean;
        en: boolean;
    };
};

如果添加了 as const 表达式,

TypeScript

.d.ts

const EnabledRegons = {
  "GB": {
    "en": true,
  },
  "IT": {
    "en": true,
    "it": true,
  },
  "PL": {
    "pl": true,
    "en": false,
  },
  "LU": {
    "fr": true,
    "de": true,
    "en": true,
  }
} as const;
declare const EnabledRegons: {
    readonly GB: {
        readonly en: true;
    };
    readonly IT: {
        readonly en: true;
        readonly it: true;
    };
    readonly PL: {
        readonly pl: true;
        readonly en: false;
    };
    readonly LU: {
        readonly fr: true;
        readonly de: true;
        readonly en: true;
    };
};

可以看到,当我们使用了 as const 表达式时,TypeScript 知道这些值不可能被修改,因此,在生成 .d.ts 文件时,TypeScript 将对象属性进行了冻结,防止类型扩大。而没有使用 as const 的 .d.ts 文件,属性值仅仅被定义为 boolean ,这就意味着可能被重新赋值。

获取国家名字

下面,我们需要一个函数实现根据国家代码返回国家名字。这个简单:

const countryCodeToName = (countryCode: string): string => {
  switch (countryCode) {
    case "GB": return "Great Britain";
    case "IT": return "Italy";
    case "PL": return "Poland";
    case "LU": return "Luxembourg";
  }
}

虽然上面代码中的 switch 其实已经覆盖了所有情形,但 TypeScript 还是会报错,因为我们没有给 switch 添加 default 分支。为了满足 TypeScript 的要求,我们必须添加一个 default 分支,即便我们知道这个分支永远不会走到。但实际情况并不是仅仅一个 default 分支那么简单:

  • 我们引入了一段永远不可能执行到的代码
  • 如果我们决定要移除一个地区,那么就会在应用程序中得到一段再也不会执行到的代码
  • 如果我们要添加一个地区,那么就得找找我们要在哪添加——TypeScript 可不会告诉我们要在哪加代码

这些问题的引入来自于这个函数的参数类型是 string 这么一个通用类型,而这个类型远远大于实际值的可选范围——实际值只有 GB 、 IT 、 PL 和 LU 这么四个。所以,这个函数的参数类型应该是 EnabledRegons 这个类型的所有键的集合。那么,我们可以将函数修改为:

const countryCodeToName = (countryCode: keyof typeof EnabledRegons): string => {
  switch (countryCode) {
    case "GB": return "Great Britain";
    case "IT": return "Italy";
    case "PL": return "Poland";
    case "LU": return "Luxembourg";
  }
}

现在,TypeScript 再也不会抱怨缺少 default 分支了,因为我们已经覆盖了所有路径。另外,如果你要删除地区代码,TypeScript 就会报错,因为你使用了不是 EnabledResons 的键的值,从而可以很容易找到需要删除的代码。如果要添加新的地区,TypeScript 同样会报错,因为我们没有覆盖所有情况。

要理解 keyof typeof 的使用,我们首先要理解字面量类型 literal types 以及字面量类型的联合 union of literal types 。

字面量类型

我们可以把字面量类型理解成一种更特殊的 string 、 number 或者 boolean 。比如, "Hello, world!" 是 string ,但 string 不是 "Hello, world!" 。 "Hello, world!" 是一种更特殊的 string ,因此它是字面量类型。

我们可以这样定义字面量类型:

type Greeting = "Hello";

当我们将一个变量定义为字面量类型时,意味着这个变量只能接受这个字面量。例如:

let greeting: Greeting;
greeting = "Hello"; // OK
greeting = "Hi";    // Error: Type '"Hi"' is not assignable to type '"Hello"'

这里,因为 Greeting 是一个字面量类型,所以,变量 greeting 只能赋值为这个字面量的值,其它任何值都是不允许的。

这很像是常量,但常量可以初始化为任意值,常量不是一种类型,只是一个值,而字面量仅允许单一值,是一种类型。

单一的字面量类型作用并不大,更大的作用是将若干字面量联合起来,也就是字面量的联合:

type Greeting = "Hello" | "Hi" | "Welcome";

现在, Greeting 类型更强大了:

let greeting: Greeting;
greeting = "Hello";       // OK
greeting = "Hi";          // OK
greeting = "Welcome";     // OK
greeting = "GoodEvening"; // Error: Type '"GoodEvening"' is not assignable to type 'Greeting'

利用这种技术,我们其实是创建了一个仅包含有限个元素的集合。利用这个集合,我们将变量的可选值限制在一定的范围内。

keyof

那么, keyof 运算符是什么意思呢? keyof T 的含义是,返回一个新的字面量类型的联合类型,其中,字面量类型来自于这个 T 类型中所有的属性名。例如:

interface Person {
  name: string;
  age: number;
  location: string;
}

对 Person 类型使用 keyof 运算符:

type SomeType = keyof Person; // "name" | "age" | "location"

然后,我们就可以使用这个类型了:

let obj: SomeType;
obj = "name";      // OK
obj = "age";       // OK
obj = "location";  // OK
obj = "something"; // Error...

keyof typeof

typeof 运算符是 JavaScript 的运算符,作用是返回一个变量的类型。

上面的例子中,我们已经知道 Persion 类型,因此可以直接对其使用 keyof 运算符。但如果我们只有一个变量,并不知道具体的类型,就不能直接使用 keyof 了。此时,我们就需要先使用 typeof 运算符,获得这个变量的类型,然后再使用 keyof 运算符。

const persion = {
  name: "Tome",
  age: 12
};

type NewType = keyof typeof persion;
let newType: NewType;
newType = "name";     // OK
newType = "age";      // OK
newType = "newValue"; // Error...

上面我们看到 keyof typeof 作用于一个对象。那么,如果是枚举呢?

在 TypeScript 中,枚举是编译期的类型,等同于编译期类型安全的常量;但在运行时,枚举退化为一个对象。这一点我们可以由 TypeScript 的编译结果看出。例如,

enum Colors {
    white = '#ffffff',
    black = '#000000',
}

经过编译之后为:

"use strict";
var Colors;
(function (Colors) {
    Colors["white"] = "#ffffff";
    Colors["black"] = "#000000";
})(Colors || (Colors = {}));

因此,针对枚举使用 keyof typeof 运算符,与针对对象并没有本质的区别。

type ColorTypes = keyof typeof Colors

let colorLiteral: ColorTypes;
colorLiteral = "white"  // OK
colorLiteral = "black"  // OK
colorLiteral = "red"    // Error...

如果你想知道一段 TypeScript 代码被翻译成怎样的 JavaScript 代码,可以打开 TypeScript 的官方网站的页面: https://www.typescriptlang.org/play 。这里以左右对照的形式展示了 TypeScript 的翻译结果。

根据地区对语言进行建模

假如我们有一个函数 getUrl() ,可以根据地区代码和语言代码返回一个适用于这个地区的这个语言的 URL。为严格起见,我们必须按照前面的那个配置信息调用这个函数,当传入了不匹配的地区和语言时,函数就会报错:

etUrl("GB", "en"); // 正确
getUrl("IT", "it"); // 正确
getUrl("IT", "pl"); // 错误,因为 IT 没有 pl 语言

幸运的是,我们可以使用类型参数推断 type argument inference 对这个函数进行改造:

const getUrl = <CountryCode extends keyof typeof EnabledRegons>
  (country: CountryCode,
  language: keyof typeof EnabledRegons[CountryCode]): string => {
    // body of our function
}

这种技术对于一个参数依赖于另外参数的情形尤其重要。其中一个很常见的应用场景是 addEventListener 函数:该函数根据事件类型,确定其回调函数的参数类型。实际代码可以在 这里 找到。我们将其摘录出来:

addEventListener<K extends keyof htmlElementEventMap>(type: K, listener: (this: HTMLAnchorElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;

注意,这里的 HTMLElementEventMap 是以字符串为键、 Event 对象类型为值的对照关系。

动态创建 Locale 字符串

如果你曾注意到多语言网站,往往有一种同时指定地区和语言的字符串:Locale。这种字符串包含有地区代码和语言代码,并且以连字符相连。我们重构一下前面的 getUrl() 函数,使用 locale 字符串作为参数类型。这意味着,我们必须能够动态创建 locale 字符串。幸运的是,我们有映射类型:

type ValueOf<T> = T[keyof T];

type Locale = ValueOf<{[K in Region]: `${keyof typeof EnabledRegons[K] & string}-${K}`}>

Locale 类型通过遍历地区代码进行创建,同时,对于每一个地区,生成一个包含了语言代码的地区代码。这里有一个小小的技巧 & string 。这是因为 keyof 运算符返回类型是 string | number | symbol ,我们需要告诉 TypeScript,我们仅关心 string 类型。

这样,TypeScript 还可以帮我们实现代码提示:

62cd16201f2d8.jpg

下面我们可以继续改进代码。我们可以根据配置信息,自动排除禁用掉的语言。

type ExcludeFalseValues<T> = {[K in keyof T as T[K] extends true ? K : never]: T[K] }

type Locale = ValueOf<{
    [K in Region]: `${keyof ExcludeFalseValues<typeof EnabledRegons[K]> & string}-${K}`
}>

这段代码使用 never 过滤掉不需要的值。当一个值不是扩展自 true 时,也就是 TypeScript 或去检查是否相同,我们通过设置 never 告诉 TypeScript 忽略掉这个属性。

现在,我们的代码已经足够智能,当我们禁用掉某一地区的某一语言时,TypeScript 就可以检测出来:

62cd16275d68c.jpg

这样技术对于找出与停用的代码相关的其它代码,或者在维护翻译时,确保不同语言之间的值是同步的时,尤其有用。

TypeScript 的映射类型以及其它相关技术,对于创建更严格的类型代码非常有帮助。另一方面,虽然看起来繁琐,但这种代码通常会带来很多意想不到的好处。

原文 https://www.devbean.net/2022/07/ts-mapped-types/

链接: https://www.fly63.com/article/detial/11877


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK