TypeScript 类型编程

本文最后更新于:2020年12月29日 下午

前言

TypeScript 改变了吾辈对于类型系统的认知,它强大的类型系统使得类型本身也是可编程的。

最近 TypeScript 更新了一个大版本 v4,新增了一些非常强大的特性,让之前难以做到的事情也能够实现了。

  • 模板字符串类型
  • 递归类型

简介

参考:TypeScript 4.1

模板字符串类型

尤其适合在 CSS Properties 相关的类型定义中使用,例如 css 的 width,就可以使用模板字符串进行检查避免低级错误。

之前

1
2
3
type Width = number | string;
const i: Width = "1px";
const i2: Width = "1px2"; // 不会报错

现在

1
2
3
4
type Unit = "px" | "%" | "em" | "rem";
type Width = number | `${number}${Unit}`;
const i: Width = "1px";
const i2: Width = "1px2"; // TS2322: Type '"1px2"' is not assignable to type 'Width'.

该特性在 Grid 组件进行了实用,能够避免一些低级错误。

递归类型

事实上,递归类型的需求由来已久。例如典型的 Array.prototype.flat 函数的类型定义,或是函数式中部分应用函数的类型定义。

下面是一个将嵌套数组亚平的函数的类型定义示例(来自 TypeScript 官网)

1
2
3
4
5
6
7
8
9
10
type ElementType<T> = T extends ReadonlyArray<infer U> ? ElementType<U> : T;

function deepFlatten<T extends readonly unknown[]>(x: T): ElementType<T>[] {
throw "not implemented";
}

// All of these return the type 'number[]':
deepFlatten([1, 2, 3]);
deepFlatten([[1], [2, 3]]);
deepFlatten([[1], [[2]], [[[3]]]]);

实际的使用场景

在 i18next 中根据 key 获取翻译字符串

i18next 的类型定义

最近遇到了通过 i18n 框架获取翻译文本的需求,其中翻译文本通过一个对象的形式定义,所以吾辈就需要一种能够根据 key 获取到类型的方法。

需要支持以下情况

  • 根据 key 获取对应的文本
  • 根据 key 深层获取文本
  • 根据 key 获取文本并进行参数注入

下面是吾辈对 i18next 的封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import zhCN from "../i18n/zhCN";
import en from "../i18n/en";
import i18next, { TOptions } from "i18next";

//region 类型定义

// returns the same string literal T, if props match, else never
type CheckDictString<T extends string, O> = T extends `${infer A}.${infer B}`
? A extends keyof O
? `${A}.${Extract<CheckDictString<B, O[A]>, string>}`
: never
: T extends keyof O
? T
: never;

// returns the property value from object O given property path T
type GetDictValue<T extends string, O> = T extends `${infer A}.${infer B}`
? A extends keyof O
? GetDictValue<B, O[A]>
: never
: T extends keyof O
? O[T]
: never;

// retrieves all variable placeholder names as tuple
type Keys<S extends string> = S extends ""
? []
: S extends `${infer _}{{${infer B}}}${infer C}`
? [B, ...Keys<C>]
: never;

// substitutes placeholder variables with input values
type Interpolate<
S extends string,
I extends Record<Keys<S>[number], string>
> = S extends ""
? ""
: S extends `${infer A}{{${infer B}}}${infer C}`
? `${A}${I[Extract<B, keyof I>]}${Interpolate<C, I>}`
: never;

//endregion

type Dict = typeof zhCN | typeof en;

export enum LanguageEnum {
ZhCN = "zhCN",
En = "en",
}

export class I18nLoader {
constructor() {}

/**
* 加载国际化
*/

async load(language: LanguageEnum) {
await i18next.init({
lng: language,
resources: {
en: {
translation: en,
},
zhCN: {
translation: zhCN,
},
},
});
}

/**
* 根据 key 获取翻译的文本
* @param key
*/
getText<K extends string>(
key: keyof Dict | (K & CheckDictString<K, Dict>)
): GetDictValue<K, Dict>;
getText<
D extends Dict & Record<string, string>,
K extends keyof D,
I extends Record<Keys<D[K]>[number], string>
>(k: K, args: I): Interpolate<D[K], I>;
getText<K extends string>(
key: keyof Dict | (K & CheckDictString<K, Dict>),
args?: TOptions
): GetDictValue<K, Dict> {
return i18next.t(key, args);
}
}

使用起来很简单

1
2
3
const i18nLoader = new I18nLoader();
await i18nLoader.load(LanguageEnum.ZhCN);
console.log(i18nLoader.getText("hello")); // 你好

接下来,我们分析以下 CheckDictString

1
2
3
4
5
6
7
type CheckDictString<T extends string, O> = T extends `${infer A}.${infer B}`
? A extends keyof O
? `${A}.${Extract<CheckDictString<B, O[A]>, string>}`
: never
: T extends keyof O
? T
: never;
  1. 传入泛型参数 TOT 必须继承自 string
  2. 判断 T 是否继承自 ${infer A}.${infer B},即判断 T 是否包含 .,并解构得到 A(第一个 . 之前),B(第一个 . 之后,可能还包含 .
    1. 如果是,则继续判断 A 是否为传入对象的字段
      1. 如果是,则继续递归检查 B 是否为 O[A] 的一个字段
      2. 否则,返回 never
    2. 否则,则判断 T 是否是 O 的字段
      1. 如果是,则返回 T
      2. 否则,返回 never

可以看到,如果检查出现错误,则返回 never,但我们传入的 string 是不能合并为 never 的,这将会导致 ts 类型检查出错(其它的类型基本上也是一样的推导方式)。


附录

虽然看起来不错,那么这个类型是否满足我们简化 i18next 使用的需求呢?
实际上没有。即便有如此强大的类型系统,但它仍然不足以满足特别灵活的需求。实际使用时仍发现以下问题:

  • 嵌套对象的参数注入没有进行检查
  • 参数注入的翻译文本没有提示注入参数

解决方案有两个方向

  • linter rule
  • code generate

下面是一个简单的对比

分类 typescript linter rule code generate
使用 直接使用 通过 eslint 插件 通过 cli 命令行
复杂度 一般(不用了解 ast 较高
适用场景 绝大多数场景 容易编写的少量代码的检查 大量重复可自动化生成的代码

使用类型系统解析 json 字符串(好玩性质)

twitter 上 甚至有人使用 TypeScript 的模板字符串和递归类型解析了 json 字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
type ParserError<T extends string> = { error: true } & T;
type EatWhitespace<State extends string> = string extends State
? ParserError<"EatWhitespace got generic string type">
: State extends ` ${infer State}` | `\n${infer State}`
? EatWhitespace<State>
: State;
type AddKeyValue<
Memo extends Record<string, any>,
Key extends string,
Value extends any
> = Memo & { [K in Key]: Value };
type ParseJsonObject<
State extends string,
Memo extends Record<string, any> = {}
> = string extends State
? ParserError<"ParseJsonObject got generic string type">
: EatWhitespace<State> extends `}${infer State}`
? [Memo, State]
: EatWhitespace<State> extends `"${infer Key}"${infer State}`
? EatWhitespace<State> extends `:${infer State}`
? ParseJsonValue<State> extends [infer Value, `${infer State}`]
? EatWhitespace<State> extends `,${infer State}`
? ParseJsonObject<State, AddKeyValue<Memo, Key, Value>>
: EatWhitespace<State> extends `}${infer State}`
? [AddKeyValue<Memo, Key, Value>, State]
: ParserError<`ParseJsonObject received unexpected token: ${State}`>
: ParserError<`ParseJsonValue returned unexpected value for: ${State}`>
: ParserError<`ParseJsonObject received unexpected token: ${State}`>
: ParserError<`ParseJsonObject received unexpected token: ${State}`>;
type ParseJsonArray<
State extends string,
Memo extends any[] = []
> = string extends State
? ParserError<"ParseJsonArray got generic string type">
: EatWhitespace<State> extends `]${infer State}`
? [Memo, State]
: ParseJsonValue<State> extends [infer Value, `${infer State}`]
? EatWhitespace<State> extends `,${infer State}`
? ParseJsonArray<EatWhitespace<State>, [...Memo, Value]>
: EatWhitespace<State> extends `]${infer State}`
? [[...Memo, Value], State]
: ParserError<`ParseJsonArray received unexpected token: ${State}`>
: ParserError<`ParseJsonValue returned unexpected value for: ${State}`>;
type ParseJsonValue<State extends string> = string extends State
? ParserError<"ParseJsonValue got generic string type">
: EatWhitespace<State> extends `null${infer State}`
? [null, State]
: EatWhitespace<State> extends `"${infer Value}"${infer State}`
? [Value, State]
: EatWhitespace<State> extends `[${infer State}`
? ParseJsonArray<State>
: EatWhitespace<State> extends `{${infer State}`
? ParseJsonObject<State>
: ParserError<`ParseJsonValue received unexpected token: ${State}`>;
type ParseJson<T extends string> = ParseJsonValue<T> extends infer Result
? Result extends [infer Value, string]
? Value
: Result extends ParserError<any>
? Result
: ParserError<"ParseJsonValue returned unexpected Result">
: ParserError<"ParseJsonValue returned uninferrable Result">;

type Json = ParseJson<'{ "key1": ["value1", null], "key2": "value2" }'>; // type Json = { key1: ["value1", null]; } & { key2: "value2"; }

我想后端语言(Java/GoLang)至今也没有出现如此复杂的类型定义。

总结

虽然 TypeScript 的类型系统已经如此强大,但它并非没有局限性,像在上面的 在 i18next 中根据 key 获取翻译字符串 便是一例。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!