原文地址: https://medium.com/hypersp here-codes/advanced-typescript-mapped-types-and-more-b5d023bd6539
使用强类型语言会带来很多好处,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
表达式时,
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
表达式,
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];