高级数据类型

状态: 初稿

聚合类型

聚合将多个类型合并为一体.
它的产物叫做聚合类型, 拥有参与聚合的每个单一类型所有功能.
例如, Person & Serializable & Loggable 既是 Person, 又是 Serializable, 它还是 Loggable.
该类型的实例包含来自三个单一类型的所有成员.

你几乎只能看到人们为不符合典型面向对象模型的如 mixins 等概念使用聚合类型.
(JavaScript 里面有很多!)
以下例子演示如何创建 mixin:

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
function extend<First, Second>(first: First, second: Second): First & Second {
const result: Partial<First & Second> = {};
for (const prop in first) {
if (first.hasOwnProperty(prop)) {
(result as First)[prop] = first[prop];
}
}
for (const prop in second) {
if (second.hasOwnProperty(prop)) {
(result as Second)[prop] = second[prop];
}
}
return result as First & Second;
}

class Person {
constructor(public name: string) { }
}

interface Loggable {
log(name: string): void;
}

class ConsoleLogger implements Loggable {
log(name) {
console.log(`Hello, I'm ${name}.`);
}
}

const jim = extend(new Person('Jim'), ConsoleLogger.prototype);
jim.log(jim.name);

自适应类型

自适应类型和聚合类型有着紧密的联系, 但两者用法却大相径庭.
偶尔, 你会遇到这样的库函数, 它期待参数是 number, 或 string.
举个例子, 观察以下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Takes a string and adds "padding" to the left.
* If 'padding' is a string, then 'padding' is appended to the left side.
* If 'padding' is a number, then that number of spaces is added to the left side.
*/
function padLeft(value: string, padding: any) {
if (typeof padding === "number") {
return Array(padding + 1).join(" ") + value;
}
if (typeof padding === "string") {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}

padLeft("Hello world", 4); // returns " Hello world"

padding 参数类型是 any, 这是有问题的.
它说明我们可以向 padding 传一个既不是 number 又不是 string 的实参, 而 TypeScript 不加干预.

1
let indentedString = padLeft("Hello world", true); // passes at compile time, fails at runtime.

依照传统面向对象思路, 我们可以创建一个类型层次抽象这两个类型.
它很直观, 却有点小题大做.
原始 padLeft 版本值得称道的一点是我们可以仅传入原始类型.
简单而干练.
新方法对别处已存在的函数也不起作用.

综合考虑, 我们应该选择自适应类型代替any:

1
2
3
4
5
6
7
8
9
10
/**
* Takes a string and adds "padding" to the left.
* If 'padding' is a string, then 'padding' is appended to the left side.
* If 'padding' is a number, then that number of spaces is added to the left side.
*/
function padLeft(value: string, padding: string | number) {
// ...
}

let indentedString = padLeft("Hello world", true); // errors during compilation

自适应类型可以表示多种类型的值.
我们以竖线 (|) 分隔单个类型, number | string | boolean 是一个可能是 number, 可能是 string, 又可能是 boolean 的值的类型.

我们只能访问一个自适应值所有单个类型共有的成员.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface Bird {
fly();
layEggs();
}

interface Fish {
swim();
layEggs();
}

function getSmallPet(): Fish | Bird {
// ...
}

let pet = getSmallPet();
pet.layEggs(); // okay
pet.swim(); // errors

自适应类型有点复杂, 但我们靠直觉就能掌握.
如果一个值的类型是 A | B, 我们只能确定它包含 AB 共有的成员.
上例中, Bird 有一个叫做 fly 的成员.
但是, 我们不能确定一个 Bird | Fish 变量也有 fly 方法.
在运行期间, 如果变量存储的确是 Fish, 调用 fly 必定出错.

类型护卫以及类型区分

自适应类型可以对值所代表的类型有机会重叠的情形建模.
当我们正好需要确定一个变量是不是 Fish 时应该怎么做?
JavaScript 区分两个值的一种惯用表达方式是检查特定成员是否存在.
我们说过, 你只能访问每个自适应类型成分都有的成员.

1
2
3
4
5
6
7
8
9
let pet = getSmallPet();

// Each of these property accesses will cause an error
if (pet.swim) {
pet.swim();
}
else if (pet.fly) {
pet.fly();
}

要让以上实例工作, 我们用类型担保:

1
2
3
4
5
6
7
let pet = getSmallPet();

if ((pet as Fish).swim) {
(pet as Fish).swim();
} else if ((pet as Bird).fly) {
(pet as Bird).fly();
}

自定义类型护卫

可以看到我们多次使用类型担保.
如果我们做完检查, 就知道 pet 在每个分支内部的类型, 无疑会更好.

TypeScript 所谓类型护卫使其得以实现.
类型护卫是在运行期执行检查的某种表达式, 确保特定范围内的变量类型.

使用类型断言

要定义类型护卫, 创建一个返回值类型是类型断言的函数:

1
2
3
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}

在这个例子中, pet is Fish 便是类型断言.
类型断言采用 参数名 is 类型 的形式, 其中, 参数名必须是当前函数一个参数的名字.

当你用某个变量调用 isFish, 如果变量类型满足断言, TypeScript 收缩这变量至断言类型.

1
2
3
4
5
6
7
8
// Both calls to 'swim' and 'fly' are now okay.

if (isFish(pet)) {
pet.swim();
}
else {
pet.fly();
}

上例, TypeScript 不仅知道在 if 分支内部, pet 是一个 Fish;
它还知道在 else 分支, pet 不可能也是 Fish, 所以它只能是 Bird.

使用 in 运算符

in 操作符现在起着收缩类型的作用.

对表达式 n in x, n 是 string 字面量, 或 string 字面量类型, 而 x 是一个自适应类型, “真” 分支把类型收缩为有一个可选或必要的属性 n, “假” 分支把类型收缩为有一个可选属性 n, 或没有属性 n.

1
2
3
4
5
6
function move(pet: Fish | Bird) {
if ("swim" in pet) {
return pet.swim();
}
return pet.fly();
}

typeof 型

我们回过头重写一版 padLeft, 这次采用自适应类型.
结合类型断言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function isNumber(x: any): x is number {
return typeof x === "number";
}

function isString(x: any): x is string {
return typeof x === "string";
}

function padLeft(value: string, padding: string | number) {
if (isNumber(padding)) {
return Array(padding + 1).join(" ") + value;
}
if (isString(padding)) {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}

可以说, 定义一个函数去弄明白一个类型是不是原始类型稍显痛苦.
幸而, 即使你不把 typeof x === "number" 放进它独有的函数, TypeScript 也能直接识别它是一个类型护卫.
由此, 我们可以把它们精简至一行.

1
2
3
4
5
6
7
8
9
function padLeft(value: string, padding: string | number) {
if (typeof padding === "number") {
return Array(padding + 1).join(" ") + value;
}
if (typeof padding === "string") {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}

typeof 型类型护卫有两种形式: typeof v === "typename"typeof v !== "typename", "typename" 只能从 "number", "string", "boolean", 或 "symbol" 中选择.
TypeScript 不阻止你与其他字符串比较, 但语言不会将它们识别为类型护卫.

instanceof 型

如果, 你已经读完 typeof 型类型护卫, 再加上你熟悉 JavaScript instanceof 运算符, 不难猜出这节所要讲的内容.

instanceof 型类型护卫是一种运用构造器函数来收缩类型的方法.
我们借用先前工业化标准的 string-padder 例子:

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
interface Padder {
getPaddingString(): string
}

class SpaceRepeatingPadder implements Padder {
constructor(private numSpaces: number) { }
getPaddingString() {
return Array(this.numSpaces + 1).join(" ");
}
}

class StringPadder implements Padder {
constructor(private value: string) { }
getPaddingString() {
return this.value;
}
}

function getRandomPadder() {
return Math.random() < 0.5 ?
new SpaceRepeatingPadder(4) :
new StringPadder(" ");
}

// Type is 'SpaceRepeatingPadder | StringPadder'
let padder: Padder = getRandomPadder();

if (padder instanceof SpaceRepeatingPadder) {
padder; // type narrowed to 'SpaceRepeatingPadder'
}
if (padder instanceof StringPadder) {
padder; // type narrowed to 'StringPadder'
}

instanceof 右端应是一个构造器函数, TypeScript 按下列顺序收缩类型至:

  1. 如果类型不是 any, 函数 prototype 属性的类型
  2. 类型构造签名返回的自适应类型

可为空类型

TypeScript 有两种特殊数据类型, nullundefined, 它们分别表示 null 和 undefined 两种特殊值.
基本数据类型一章有它们的简要介绍.
默认情况, 类型检查器认为值 nullundefined 可以赋给任何类型.
而实际上, nullundefined 就是所有类型的有效值.
这说明即使你有意避免, 也无法阻止它们能赋值给任何类型的能力.
null 的发明者, Tony Hoare, 称之为“billion dollar mistake”(一百万美元的失误).

--strictNullChecks 选项在编译器层面改善: 你定义变量的时候, nullundefined 不自动添加进值域中.
你可以运用自适应类型显式添加它们:

1
2
3
4
5
6
let s = "foo";
s = null; // error, 'null' is not assignable to 'string'
let sn: string | null = "bar";
sn = null; // ok

sn = undefined; // error, 'undefined' is not assignable to 'string | null'

注意, 为符合 JavaScript 语义, TypeScript 区分对待 nullundefined.
stirng | nullstirng | undefinedstring | undefined | null 是不同的类型.

在 TypeScript 3.7 以及更新的版本中, 你可以借助optional chaining(可选链)简化对可为空类型的使用.

可选参数和属性

有了 --strictNullChecks, 可选参数的类型自动附加 | undefined:

1
2
3
4
5
6
7
function f(x: number, y?: number) {
return x + (y || 0);
}
f(1, 2);
f(1);
f(1, undefined);
f(1, null); // error, 'null' is not assignable to 'number | undefined'

可选属性也一样:

1
2
3
4
5
6
7
8
9
10
class C {
a: number;
b?: number;
}
let c = new C();
c.a = 12;
c.a = undefined; // error, 'undefined' is not assignable to 'number'
c.b = 13;
c.b = undefined; // ok
c.b = null; // error, 'null' is not assignable to 'number | undefined'

类型护卫与类型担保

由于可为空类型是依靠自适应类型得以实现的, 你需要用类型护卫消除 null 值.
它看起来和 JavaScript 一样:

1
2
3
4
5
6
7
8
function f(sn: string | null): string {
if (sn == null) {
return "default";
}
else {
return sn;
}
}

这里对 null 的消除意图是很明显的, 但是你也可以用更简练的运算符:

1
2
3
function f(sn: string | null): string {
return sn || "default";
}

我们有类型担保运算符, 在编译器不能消除 nullundefined 时发挥作用.
语法是感叹号后缀 !: identifier!identifier 的类型消除 nullundefined:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function broken(name: string | null): string {
function postfix(epithet: string) {
return name.charAt(0) + '. the ' + epithet; // error, 'name' is possibly null
}
name = name || "Bob";
return postfix("great");
}

function fixed(name: string | null): string {
function postfix(epithet: string) {
return name!.charAt(0) + '. the ' + epithet; // ok
}
name = name || "Bob";
return postfix("great");
}

这里有用到嵌套函数, 因为编译器不能消除嵌套函数里面的 null (立时调用函数表达式除外).
这是由于编译器无法追踪所有对嵌套函数的调用, 特别是你在外层函数返回它时.
无从得知函数在哪里调用, 它也无法确定函数体执行期间 name 的类型.

类型别名

类型别名为类型创建一个新名字.
有时类型别名如同接口, 不过, 它可以为原始类型, 自适应类型, 元组, 以及其他你需要手动书写的类型命名.

1
2
3
4
5
6
7
8
9
10
11
type Name = string;
type NameResolver = () => string;
type NameOrResolver = Name | NameResolver;
function getName(n: NameOrResolver): Name {
if (typeof n === "string") {
return n;
}
else {
return n();
}
}

创建类型别名实际上没创建新类型 - 它只不过创建了一个新名字引用现有类型.
若非你有文档意图, 为原始类型创建别名都不十分有用.

如同接口, 别名也可以是泛型的 - 我们可以附加类型参数进而在别名定义右侧使用.

1
type Container<T> = { value: T };

我们可以在属性定义中让别名引用自身:

1
2
3
4
5
type Tree<T> = {
value: T;
left: Tree<T>;
right: Tree<T>;
}

结合聚合类型, 我们可以创造出相当烧脑的类型:

1
2
3
4
5
6
7
8
9
10
11
type LinkedList<T> = T & { next: LinkedList<T> };

interface Person {
name: string;
}

var people: LinkedList<Person>;
var s = people.name;
var s = people.next.name;
var s = people.next.next.name;
var s = people.next.next.next.name;

并不是说, 别名本身可以出现在定义右侧其他任何地方:

1
type Yikes = Array<Yikes>; // error

接口对比类型别名

我们提到过, 类型别名在某些方面与接口有相似之处, 然而, 两者还是存在一些细微差别.

第一个差别是, 接口创建在所有场合使用的新名字.
类型别名则不这样 — 举例来说, 错误信息不使用别名.
考察以下代码, 在编辑器中, 把鼠标悬浮在 interfaced 上方, 编辑器显示函数返回 Interface, 把鼠标悬浮在 aliased 上方, 则显示函数返回对象字面量类型.

1
2
3
4
5
6
type Alias = { num: number }
interface Interface {
num: number;
}
declare function aliased(arg: Alias): Alias;
declare function interfaced(arg: Interface): Interface;

在较老的 TypeScript 版本中, 类型别名既不能被继承/实现, 也不能继承/实现其他类型. 自 2.7 版开始, 你可以创建聚合类型来继承类型别名, 例如: type Cat = Animal & { purrs: true }.

因为an ideal property of software is being open to extension(软件的理想属性是向扩展开放), 如果可行, 总是优先选用接口.

另一方面, 面对你无法用接口表达的形体, 而需要借助自适应类型和元组的时候, 类型别名便成为一种选择.

字符串字面量类型

字符串字面量类型允许你规定它的变量只能表示的定值.
实践中, 字符串字面量类型总与自适应类型, 类型护卫, 类型别名结合使用.
可以用它们模拟字符串枚举.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Easing = "ease-in" | "ease-out" | "ease-in-out";
class UIElement {
animate(dx: number, dy: number, easing: Easing) {
if (easing === "ease-in") {
// ...
}
else if (easing === "ease-out") {
}
else if (easing === "ease-in-out") {
}
else {
// error! should not pass null or undefined.
}
}
}

let button = new UIElement();
button.animate(0, 0, "ease-in");
button.animate(0, 0, "uneasy"); // error: "uneasy" is not allowed here

你可以传递三个给定字符串中任意一个调用 animate, 传递其他字符串将会报错

1
Argument of type '"uneasy"' is not assignable to parameter of type '"ease-in" | "ease-out" | "ease-in-out"'

字符串字面量类型还可以用以区分函数重载:

1
2
3
4
5
6
function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
// ... more overloads ...
function createElement(tagName: string): Element {
// ... code goes here ...
}

数值字面量类型

TypeScript 也有数值字面量类型.

1
2
3
function rollDice(): 1 | 2 | 3 | 4 | 5 | 6 {
// ...
}

它们应用范围不广, 多用于在缩小问题范围, 定位 bug 的场景:

1
2
3
4
5
6
function foo(x: number) {
if (x !== 1 || x !== 2) {
// ~~~~~~~
// Operator '!==' cannot be applied to types '1' and '2'.
}
}

换句话说, 在 x2 比较时它必须是 1, 以上检查造成了一个无效比较.
译注: 要么 x !== 1 为真, 整个表达式为真, 要么 x !== 1 为假, 继续求 x !== 2 的值, 而 x !== 1 为假说明 x === 1, 第二个表达式必为真.

枚举成员类型

枚举一章提过, 如果每个枚举成员都是用字面量初始化的, 枚举成员也有它们的类型.

在我们谈论单例类型(singleton types)的多数时候, 我们指的是枚举成员类型或字符串/数值字面量类型, 有很多用户会混用单例类型和字面量类型.

区辨联合

你可以组合单例类型, 自适应类型, 类型护卫, 和类型别名构造出一种所谓区辨联合的高级模式, 也可以叫标签联合, 或代数数据类型.
区辨联合广泛用于函数式编程.
有些语言自动为你区辨联合, 而 TypeScript 的区辨联合建立在现有 JavaScript 模式之上.
区辨联合有三种成分:

  1. 有一个共同单例类型属性的多个类型 — 区辨.
  2. 一个联合了 1 中所有类型的类型别名 — 联合.
  3. 针对 1 中共同属性的类型护卫.
1
2
3
4
5
6
7
8
9
10
11
12
13
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}

首先, 定义要参与联合的接口.
每个接口都有一个属于不同字符串字面量类型的 kind 属性.
这里的 kind 就叫做区辨或标签.
其他属性都是各个接口特有的.
注意, 到目前为止, 各个接口毫无关联.
下面, 将它们组成联合:

1
type Shape = Square | Rectangle | Circle;

使用区辨联合:

1
2
3
4
5
6
7
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}

完全覆盖检查

我们想让编译器告知我们没有覆盖区辨联合所有成员.
比如说, 当添加新类型 TriangleShape 中, 提醒我们同时更新 area 函数:

1
2
3
4
5
6
7
8
9
type Shape = Square | Rectangle | Circle | Triangle;
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
// should error here - we didn't handle case "triangle"
}

有两种方法可达成目的.
第一, 打开 --strictNullChecks 选项, 并指明函数返回值类型:

1
2
3
4
5
6
7
function area(s: Shape): number { // error: returns number | undefined
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}

鉴于 switch 不再全面覆盖 Shape, TypeScript 担心函数有机会返回 undefined.
如果函数有显式返回值类型 number, 它就会报错, 因为实际返回值类型是 number | undefined.
不过, 这个方法过于隐晦, 除此之外, --strictNullChecks 不总是兼容旧代码.

第二种, 编译器利用 never 类型检查覆盖情况:

1
2
3
4
5
6
7
8
9
10
11
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
default: return assertNever(s); // error here if there are missing cases
}
}

这里, assertNever 检查 s 的类型是 never — 所有其他事件都移除后剩下的类型.
如果你遗漏某个事件, s 获得实际类型, 你也得到一个类型错误.
这方法需要你定义一个新函数, 忘记定义就会很明显.

多态 this 类型

多态 this 类型代表所属类或接口的子类型.
我们称之为 F-有界多态性.
它让我们更容易表达分层流式接口.
以每个操作都返回 this 的简单计算器作为示例:

译注: 观察下例的返回值类型, 而不是函数体内的 this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class BasicCalculator {
public constructor(protected value: number = 0) { }
public currentValue(): number {
return this.value;
}
public add(operand: number): this {
this.value += operand;
return this;
}
public multiply(operand: number): this {
this.value *= operand;
return this;
}
// ... other operations go here ...
}

let v = new BasicCalculator(2)
.multiply(5)
.add(1)
.currentValue();

因为类运用了 this 类型, 你可以继承该类, 新类不经修改就能沿用旧类的方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ScientificCalculator extends BasicCalculator {
public constructor(value = 0) {
super(value);
}
public sin() {
this.value = Math.sin(this.value);
return this;
}
// ... other operations go here ...
}

let v = new ScientificCalculator(2)
.multiply(5)
.sin()
.add(1)
.currentValue();

没有 this 类型, ScientificCalculator 便不能在继承 BasicCalculator 的同时保持流式接口.
multiply 只能返回没有 sin 方法的 BasicCalculator.
有了 this 类型, multiply 返回的 this 代表的是 ScientificCalculator.

索引类型

结合索引类型, 你能使编译器检查运用动态属性名的代码.
举个例子, 大家熟知的一种 JavaScript 设计模式是从一个对象挑选属性集:

译注: 下面 propertyNames 类型是 string[].

1
2
3
function pluck(o, propertyNames) {
return propertyNames.map(n => o[n]);
}

下面给出在 TypeScript 中实现这个和调用该函数的方法, 我们会用到索引类型查询, 索引访问运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function pluck<T, K extends keyof T>(o: T, propertyNames: K[]): T[K][] {
return propertyNames.map(n => o[n]);
}

interface Car {
manufacturer: string;
model: string;
year: number;
}
let taxi: Car = {
manufacturer: 'Toyota',
model: 'Camry',
year: 2014
};

// Manufacturer and model are both of type string,
// so we can pluck them both into a typed string array
let makeAndModel: string[] = pluck(taxi, ['manufacturer', 'model']);

// If we try to pluck model and year, we get an
// array of a union type: (string | number)[]
let modelYear = pluck(taxi, ['model', 'year'])

编译器检查 manufacturermodel 的确是 Car 的属性.
这个例子引入了几个新类型操作符.
首先, keyof T, 即索引类型查询运算符.
对任意类型 T, keyof TT 可知的, 公共属性名称的自适应类型.
举例如下:

1
let carProps: keyof Car; // the union of ('manufacturer' | 'model' | 'year')

keyof Car'manufacturer' | 'model' | 'year' 是完全可互换的.
区别是如果你为 Car 添加新属性, 比如 ownersAddress: string, keyof Car 自动得到更新 'manufacturer' | 'model' | 'year' | 'ownersAddress'.
plunk 等泛型语境中, 你不可能事先知道 T 的属性名集合, 就只能借助 keyof 关键字.
编译器可以据此检查你向 pluck 传递了一组正确的属性名.

1
2
// error, 'unknown' is not in 'manufacturer' | 'model' | 'year'
pluck(taxi, ['year', 'unknown']); /

第二个运算符是 T[K], 索引访问运算符.
这里, 类型语法体现出表达式语法.
意思是 person['name'] 的类型是 Person['name'] — 我们的例子: string.
就如索引类型查询, 你可以在泛型语境使用 T[K], 这是它发挥真正实力的地方.
你要做的只是确保类型参数 K extends keyof T.
再看另一个例子, getProperty 函数:

1
2
3
function getProperty<T, K extends keyof T>(o: T, propertyName: K): T[K] {
return o[propertyName]; // o[propertyName] is of type T[K]
}

getProperty 中, o: TpropertyName: K 的含义是 o[propertyName]: T[K].
每当你返回结果 T[K], 编译器实例化该键的实际类型, 所以 getProperty 的返回值类型会随着请求的属性不同而变化.

1
2
3
4
5
let name: string = getProperty(taxi, 'manufacturer');
let year: number = getProperty(taxi, 'year');

// error, 'unknown' is not in 'manufacturer' | 'model' | 'year'
let unknown = getProperty(taxi, 'unknown');

索引类型和索引签名

keyofT[K] 与索引签名相互作用. 索引签名的参数类型必须是 ‘string’ 或 ‘number’.
如果你的类型包含字符串型索引签名, keyof T 总返回 string | number.
(不只是 string, 因为在 JavaScript 中你可以通过字符串 (ojbect["42"]) 或数字 (object[42]) 来访问对象属性).
还有, T[string] 即索引签名的类型:

1
2
3
4
5
interface Dictionary<T> {
[key: string]: T;
}
let keys: keyof Dictionary<number>; // string | number
let value: Dictionary<number>['foo']; // number

如果你的类型只有数值型索引签名, keyof T 只返回 number.

1
2
3
4
5
6
interface Dictionary<T> {
[key: number]: T;
}
let keys: keyof Dictionary<number>; // number
let value: Dictionary<number>['foo']; // Error, Property 'foo' does not exist on type 'Dictionary<number>'.
let value: Dictionary<number>[42]; // number

映射类型

一个常见任务是把一个现有类型的每个属性改成”可选”的:

1
2
3
4
interface PersonPartial {
name?: string;
age?: number;
}

或是”只读”的:

1
2
3
4
interface PersonReadonly {
readonly name: string;
readonly age: number;
}

JavaScript 如此依赖此操作, 以至于 TypeScript 专门提供这种基于旧类型创建新类型的方法 — 映射类型.
类型映射按相同方式转换旧类型的每个属性以创建新类型.
比如, 你可以把所有属性转为 “只读” 的, 或 “可选” 的.
这里有几个例子:

笔记

1
2
3
4
5
6
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
type Partial<T> = {
[P in keyof T]?: T[P];
}

使用它们:

1
2
type PersonPartial = Partial<Person>;
type ReadonlyPerson = Readonly<Person>;

注意, 该方法按整体(而不是逐成员)描述新类型.
如果你想增加成员, 可结合聚合类型:

1
2
3
4
5
6
7
8
9
10
11
// Use this:
type PartialWithNewMember<T> = {
[P in keyof T]?: T[P];
} & { newMember: boolean }

// **Do not** use the following!
// This is an error!
type PartialWithNewMember<T> = {
[P in keyof T]?: T[P];
newMember: boolean;
}

下面, 我们来分析一个映射类型的各个部分:

1
2
type Keys = 'option1' | 'option2';
type Flags = { [K in Keys]: boolean };

单看语法, 有点像 for..in 用在索引签名中.
我们分三个部分理解:

  1. 类型参数 K, 它依次绑定每个属性.
  2. 字符串字面量自适应类型 Keys, 包含要迭代的属性名.
  3. 结果属性的类型.

在这个例子中, Keys 是一个硬编码的属性名列表, 属性的类型都是 boolean, 这个映射类型等同于:

1
2
3
4
type Flags = {
option1: boolean;
option2: boolean;
}

对于实际应用, 就像上面的 ReadonlyPartial.
它们依靠某些现有类型, 以某种方式转换所有属性.
于是, 到了 keyof 与索引属性操作符(原文: indexed access types, 疑误)登场的时候:

1
2
type NullablePerson = { [P in keyof Person]: Person[P] | null }
type PartialPerson = { [P in keyof Person]?: Person[P] }

把它们改写成泛型版本, 用途更广.

1
2
type Nullable<T> = { [P in keyof T]: T[P] | null }
type Partial<T> = { [P in keyof T]?: T[P] }

概括一下, 它们的属性列表都是 keyof T, 返回值类型是 T[P] 加上一些变化.
这是一个不错的用于表达任何泛型映射类型的模板.
由于这种形式的转换是homomorphic(同态)的, 意思是映射只应用在 T 的属性而不应用在别的什么东西上.
编译器知道它可以在添加新修饰符之前拷贝所有现有属性修饰符.
例如, 如果 Person.name 是 “只读” 的, Partial<Person>.name 就会是 “只读” 和 “可选” 的.

下个例子, T[P] 包裹在 Proxy<T> 类中:

1
2
3
4
5
6
7
8
9
10
11
type Proxy<T> = {
get(): T;
set(value: T): void;
}
type Proxify<T> = {
[P in keyof T]: Proxy<T[P]>;
}
function proxify<T>(o: T): Proxify<T> {
// ... wrap proxies ...
}
let proxyProps = proxify(props);

Readonly<T>Partial<T> 是如此有用, 顺理成章地, 它们已经随 PickRecord 一并包含于 TypeScript 标准库.

1
2
3
4
5
6
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
}
type Record<K extends keyof any, T> = {
[P in K]: T;
}

Readonly, Partial, Pick 都是同态的, 而 Record 却不然.
一个 Record 非同态的迹象是它不从一个输入类型拷贝属性:

1
type ThreeStringProps = Record<'prop1' | 'prop2' | 'prop3', string>

从根本上, 非同态类型创建新的属性, 也没有属性修饰符可拷贝.

从映射类型推断

你已经学会了如何包裹类型的属性, 但是还不知道怎么解开它们.
好在, 它并不难:

1
2
3
4
5
6
7
8
9
function unproxify<T>(t: Proxify<T>): T {
let result = {} as T;
for (const k in t) {
result[k] = t[k].get();
}
return result;
}

let originalProps = unproxify(proxyProps);

注意解包推断只接受同态映射类型.
如果一个映射类型不是同态的, 你必须为解包函数指定显式类型参数.

条件类型

TypeScript 2.8 引入条件类型, 为语言增加了表达非统一类型映射的能力.
条件类型根据以类型关系测试表达的条件从两个候选类型中选择一个.

1
T extends U ? X : Y

上例表示如果 T 能赋值给 U, 类型就是 X, 否则类型是 Y.

条件类型 T extends U ? X : Y 可以解析XY 二者之一, 如果条件依赖一个或多个类型参数, 解析或被推迟.
TU 包含类型参数时, 解析结果(X, Y, 或推迟)取决于类型系统是否掌握足够信息推断 T 总是能赋值给 U.

以下是一个类型可以被立即解析的例子:

1
2
3
4
declare function f<T extends boolean>(x: T): T extends true ? string : number;

// Type is 'string | number
let x = f(Math.random() < 0.5)

另一个例子, TypeName 是一个类型别名, 其采用了条件类型的嵌套:

1
2
3
4
5
6
7
8
9
10
11
12
13
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";

type T0 = TypeName<string>; // "string"
type T1 = TypeName<"a">; // "string"
type T2 = TypeName<True>; // "boolean"
type T3 = TypeName<() => void>; // "function"
type T4 = TypeName<string[]>; // "object"

以下是一个条件类型被推迟的例子 - 条件类型不选择分支, 维持原状:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface Foo {
propA: boolean;
propB: boolean;
}

declare function f<T>(x: T): T extends Foo ? string : number;

function foo<U>(x: U) {
// Has type 'U extends Foo ? string : number'
let a = f(x);

// This assignment is allowed though!
let b: string | number = a;
}

上例, 条件类型变量 a 尚未选择一个分支.
当其他代码片段调用 foo, 某个不同的类型会替代 U, 于是, TypeScript 对条件类型重新解析, 以决定能否选择一个具体分支.

同时, 条件类型能赋值给它以外的类型, 只要条件类型的每个分支对目标类型都是可赋值的.
我们能把 U extends Foo ? string : number 赋值给 string | number 的原因即是如此, 无论条件类型 U extends Foo ? string : number 在何时解析, 结果类型只有 stringnumber 两种选择.

分配式条件类型

测试类型是 “裸” 类型参数的条件类型叫做分配式条件类型.
分配式条件类型在具化时自动依照自适应类型展开.
举例说明, 用类型参数 A | B | C 替代 T 具化 T extends U ? X : Y 的结果是 (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y).

实例

1
2
3
type T10 = TypeName<string | (() => void)>;  // "string" | "function"
type T12 = TypeName<string | string[] | undefined>; // "string" | "object" | "undefined"
type T11 = TypeName<string[] | number[]>; // "object"

具化分配式条件类型 T extends U ? X : Y, 条件类型中每个 T 都分配到一个自适应类型成分 (条件类型依照自适应类型展开后, 每个 T 对应自适应类型的一个成分).
另外, X 中对 T 的引用都有附加参数限制 U (即: 在 X 中认为 TU 是可赋值的).

实例

1
2
3
4
5
6
7
type BoxedValue<T> = { value: T };
type BoxedArray<T> = { array: T[] };
type Boxed<T> = T extends any[] ? BoxedArray<T[number]> : BoxedValue<T>;

type T20 = Boxed<string>; // BoxedValue<string>;
type T21 = Boxed<number[]>; // BoxedArray<number>;
type T22 = Boxed<string | number[]>; // BoxedValue<string> | BoxedArray<number>;

注意到在 Boxed<T> “真” 分支中, T 有附加限制 any[], 从而可推出数组元素类型是 T[number]. 最后一个条件类型的展开情况也很有代表性.

条件类型的分配式特征能过滤自适应类型:

笔记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Diff<T, U> = T extends U ? never : T;  // Remove types from T that are assignable to U
type Filter<T, U> = T extends U ? T : never; // Remove types from T that are not assignable to U

type T30 = Diff<"a" | "b" | "c" | "d", "a" | "c" | "f">; // "b" | "d"
type T31 = Filter<"a" | "b" | "c" | "d", "a" | "c" | "f">; // "a" | "c"
type T32 = Diff<string | number | (() => void), Function>; // string | number
type T33 = Filter<string | number | (() => void), Function>; // () => void

type NonNullable<T> = Diff<T, null | undefined>; // Remove null and undefined from T

type T34 = NonNullable<string | number | undefined>; // string | number
type T35 = NonNullable<string | string[] | null | undefined>; // string | string[]

function f1<T>(x: T, y: NonNullable<T>) {
x = y; // Ok
y = x; // Error
}

function f2<T extends string | undefined>(x: T, y: NonNullable<T>) {
x = y; // Ok
y = x; // Error
let s1: string = x; // Error
let s2: string = y; // Ok
}

条件类型和映射类型结合后更是有用:

笔记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T];
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;

type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T];
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

interface Part {
id: number;
name: string;
subparts: Part[];
updatePart(newName: string): void;
}

type T40 = FunctionPropertyNames<Part>; // "updatePart"
type T41 = NonFunctionPropertyNames<Part>; // "id" | "name" | "subparts"
type T42 = FunctionProperties<Part>; // { updatePart(newName: string): void }
type T43 = NonFunctionProperties<Part>; // { id: number, name: string, subparts: Part[] }

如同自适应类型, 聚合类型, TypeScript 不允许条件类型递归引用自身.
下例是错误的:

实例

1
type ElementType<T> = T extends any[] ? ElementType<T[number]> : T;  // Error

条件类型的推导

在条件类型的 extends 子句中, 现在可由 infer 声明引入一个需推导的类型参数.
条件类型的 “真” 分支能够引用经推导的类型参数.
可为同一个类型参数确立若干 推导 点.

例如, 以下定义提取函数的返回值类型:

1
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

笔记

你可以嵌套条件类型, 构建一组按顺序求值的模式匹配:

1
2
3
4
5
6
7
8
9
10
11
12
type Unpacked<T> =
T extends (infer U)[] ? U :
T extends (...args: any[]) => infer U ? U :
T extends Promise<infer U> ? U :
T;

type T0 = Unpacked<string>; // string
type T1 = Unpacked<string[]>; // string
type T2 = Unpacked<() => string>; // string
type T3 = Unpacked<Promise<string>>; // string
type T4 = Unpacked<Promise<string>[]>; // Promise<string>
type T5 = Unpacked<Unpacked<Promise<string>[]>>; // string

笔记

下例表明同一个类型参数处于协变位的多个引用能推导出自适应类型:

1
2
3
type Foo<T> = T extends { a: infer U, b: infer U } ? U : never;
type T10 = Foo<{ a: string, b: string }>; // string
type T11 = Foo<{ a: string, b: number }>; // string | number

类似地, 同一个类型参数处于逆变位的多个引用能推导出聚合类型:

1
2
3
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>; // string
type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>; // string & number

推导有多个调用签名的类型 (如重载函数), 推导从最后一个签名开始 (大体上说: 最宽松的那一个).
基于参数类型的列表来解析重载不可能的.

1
2
3
4
declare function foo(x: string): number;
declare function foo(x: number): string;
declare function foo(x: string | number): string | number;
type T30 = ReturnType<Typeof foo>; // string | number

不能在类型参数限制子句中使用 infer 声明:

1
type ReturnType<T extends (...args: any[]) => infer R> = R;  // Error, not supported

要达成相同效果, 从限制子句移除类型参数, 采用条件类型:

1
2
type AnyFunction = (...args: any[]) => any;
type ReturnType<T extends AnyFunction> = T extends (...args: any[]) => infer R ? R : any;

内建条件类型

TypeScript 2.8 在 lib.d.ts 中添加了一批内建条件类型:

  • Exclude<T, U> — 从 T 中排除能赋值给 U 的类型.
  • Extract<T, U> — 从 T 中提取能赋值给 U 的类型.
  • NonNullable<T> — 从 T 中所有 nullundefined.
  • ReturnType<T> — 取得函数 T 的返回值类型.
  • InstanceType<T> — 取得构造器函数 T 的实例类型.

实例

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
type T00 = Exclude<"a" | "b" | "c" | "d", "a" | "c" | "f">;  // "b" | "d"
type T01 = Extract<"a" | "b" | "c" | "d", "a" | "c" | "f">; // "a" | "c"

type T02 = Exclude<string | number | (() => void), Function>; // string | number
type T03 = Extract<string | number | (() => void), Function>; // () => void

type T04 = NonNullable<string | number | undefined>; // string | number
type T05 = NonNullable<(() => string) | string[] | null | undefined>; // (() => string) | string[]

function f1(s: string) {
return { a: 1, b: s };
}

class C {
x = 0;
y = 0;
}

type T10 = ReturnType<() => string>; // string
type T11 = ReturnType<(s: string) => void>; // void
type T12 = ReturnType<(<T>() => T)>; // {}
type T13 = ReturnType<(<T extends U, U extends number[]>() => T)>; // number[]
type T14 = ReturnType<Typeof f1>; // { a: number, b: string }
type T15 = ReturnType<any>; // any
type T16 = ReturnType<never>; // never
type T17 = ReturnType<string>; // Error
type T18 = ReturnType<Function>; // Error

type T20 = InstanceType<Typeof C>; // C
type T21 = InstanceType<any>; // any
type T22 = InstanceType<never>; // never
type T23 = InstanceType<string>; // Error
type T24 = InstanceType<Function>; // Error

注: Exclude 是在这里建议的 Diff 类型的一个实现. 我们采用命名 Exclude 是为了不破坏已经定义了 Diff 的现有代码, 而且我们觉得那个名字能更好地传达该类型语义.

如果这篇文章对您有用,可以考虑打赏:)