高级数据类型
状态: 初稿
聚合类型
聚合将多个类型合并为一体.
它的产物叫做聚合类型, 拥有参与聚合的每个单一类型所有功能.
例如, Person & Serializable & Loggable
既是 Person
, 又是 Serializable
, 它还是 Loggable
.
该类型的实例包含来自三个单一类型的所有成员.
你几乎只能看到人们为不符合典型面向对象模型的如 mixins 等概念使用聚合类型.
(JavaScript 里面有很多!)
以下例子演示如何创建 mixin:
1 | function extend<First, Second>(first: First, second: Second): First & Second { |
自适应类型
自适应类型和聚合类型有着紧密的联系, 但两者用法却大相径庭.
偶尔, 你会遇到这样的库函数, 它期待参数是 number
, 或 string
.
举个例子, 观察以下函数:
1 | /** |
padding
参数类型是 any
, 这是有问题的.
它说明我们可以向 padding
传一个既不是 number
又不是 string
的实参, 而 TypeScript 不加干预.
1 | let indentedString = padLeft("Hello world", true); // passes at compile time, fails at runtime. |
依照传统面向对象思路, 我们可以创建一个类型层次抽象这两个类型.
它很直观, 却有点小题大做.
原始 padLeft
版本值得称道的一点是我们可以仅传入原始类型.
简单而干练.
新方法对别处已存在的函数也不起作用.
综合考虑, 我们应该选择自适应类型代替any
:
1 | /** |
自适应类型可以表示多种类型的值.
我们以竖线 (|
) 分隔单个类型, number | string | boolean
是一个可能是 number
, 可能是 string
, 又可能是 boolean
的值的类型.
我们只能访问一个自适应值所有单个类型共有的成员.
1 | interface Bird { |
自适应类型有点复杂, 但我们靠直觉就能掌握.
如果一个值的类型是 A | B
, 我们只能确定它包含 A
和 B
共有的成员.
上例中, Bird
有一个叫做 fly
的成员.
但是, 我们不能确定一个 Bird | Fish
变量也有 fly
方法.
在运行期间, 如果变量存储的确是 Fish
, 调用 fly
必定出错.
类型护卫以及类型区分
自适应类型可以对值所代表的类型有机会重叠的情形建模.
当我们正好需要确定一个变量是不是 Fish
时应该怎么做?
JavaScript 区分两个值的一种惯用表达方式是检查特定成员是否存在.
我们说过, 你只能访问每个自适应类型成分都有的成员.
1 | let pet = getSmallPet(); |
要让以上实例工作, 我们用类型担保:
1 | let pet = getSmallPet(); |
自定义类型护卫
可以看到我们多次使用类型担保.
如果我们做完检查, 就知道 pet
在每个分支内部的类型, 无疑会更好.
TypeScript 所谓类型护卫使其得以实现.
类型护卫是在运行期执行检查的某种表达式, 确保特定范围内的变量类型.
使用类型断言
要定义类型护卫, 创建一个返回值类型是类型断言的函数:
1 | function isFish(pet: Fish | Bird): pet is Fish { |
在这个例子中, pet is Fish
便是类型断言.
类型断言采用 参数名 is 类型
的形式, 其中, 参数名必须是当前函数一个参数的名字.
当你用某个变量调用 isFish
, 如果变量类型满足断言, TypeScript 收缩这变量至断言类型.
1 | // Both calls to 'swim' and 'fly' are now okay. |
上例, TypeScript 不仅知道在 if
分支内部, pet
是一个 Fish
;
它还知道在 else
分支, pet
不可能也是 Fish
, 所以它只能是 Bird
.
使用 in 运算符
in
操作符现在起着收缩类型的作用.
对表达式 n in x
, n
是 string 字面量, 或 string 字面量类型, 而 x
是一个自适应类型, “真” 分支把类型收缩为有一个可选或必要的属性 n
, “假” 分支把类型收缩为有一个可选属性 n
, 或没有属性 n
.
1 | function move(pet: Fish | Bird) { |
typeof 型
我们回过头重写一版 padLeft
, 这次采用自适应类型.
结合类型断言:
1 | function isNumber(x: any): x is number { |
可以说, 定义一个函数去弄明白一个类型是不是原始类型稍显痛苦.
幸而, 即使你不把 typeof x === "number"
放进它独有的函数, TypeScript 也能直接识别它是一个类型护卫.
由此, 我们可以把它们精简至一行.
1 | function padLeft(value: string, padding: string | number) { |
typeof
型类型护卫有两种形式: typeof v === "typename"
和 typeof v !== "typename"
, "typename"
只能从 "number"
, "string"
, "boolean"
, 或 "symbol"
中选择.
TypeScript 不阻止你与其他字符串比较, 但语言不会将它们识别为类型护卫.
instanceof 型
如果, 你已经读完 typeof
型类型护卫, 再加上你熟悉 JavaScript instanceof
运算符, 不难猜出这节所要讲的内容.
instanceof
型类型护卫是一种运用构造器函数来收缩类型的方法.
我们借用先前工业化标准的 string-padder 例子:
1 | interface Padder { |
instanceof
右端应是一个构造器函数, TypeScript 按下列顺序收缩类型至:
- 如果类型不是
any
, 函数prototype
属性的类型 - 类型构造签名返回的自适应类型
可为空类型
TypeScript 有两种特殊数据类型, null
和 undefined
, 它们分别表示 null 和 undefined 两种特殊值.
在基本数据类型一章有它们的简要介绍.
默认情况, 类型检查器认为值 null
和 undefined
可以赋给任何类型.
而实际上, null
和 undefined
就是所有类型的有效值.
这说明即使你有意避免, 也无法阻止它们能赋值给任何类型的能力.null
的发明者, Tony Hoare, 称之为“billion dollar mistake”(一百万美元的失误).
--strictNullChecks
选项在编译器层面改善: 你定义变量的时候, null
或 undefined
不自动添加进值域中.
你可以运用自适应类型显式添加它们:
1 | let s = "foo"; |
注意, 为符合 JavaScript 语义, TypeScript 区分对待 null
和 undefined
.stirng | null
与 stirng | undefined
和 string | undefined | null
是不同的类型.
在 TypeScript 3.7 以及更新的版本中, 你可以借助optional chaining(可选链)简化对可为空类型的使用.
可选参数和属性
有了 --strictNullChecks
, 可选参数的类型自动附加 | undefined
:
1 | function f(x: number, y?: number) { |
可选属性也一样:
1 | class C { |
类型护卫与类型担保
由于可为空类型是依靠自适应类型得以实现的, 你需要用类型护卫消除 null
值.
它看起来和 JavaScript 一样:
1 | function f(sn: string | null): string { |
这里对 null
的消除意图是很明显的, 但是你也可以用更简练的运算符:
1 | function f(sn: string | null): string { |
我们有类型担保运算符, 在编译器不能消除 null
或 undefined
时发挥作用.
语法是感叹号后缀 !
: identifier!
为 identifier
的类型消除 null
和 undefined
:
1 | function broken(name: string | null): string { |
这里有用到嵌套函数, 因为编译器不能消除嵌套函数里面的 null (立时调用函数表达式除外).
这是由于编译器无法追踪所有对嵌套函数的调用, 特别是你在外层函数返回它时.
无从得知函数在哪里调用, 它也无法确定函数体执行期间 name
的类型.
类型别名
类型别名为类型创建一个新名字.
有时类型别名如同接口, 不过, 它可以为原始类型, 自适应类型, 元组, 以及其他你需要手动书写的类型命名.
1 | type Name = string; |
创建类型别名实际上没创建新类型 - 它只不过创建了一个新名字引用现有类型.
若非你有文档意图, 为原始类型创建别名都不十分有用.
如同接口, 别名也可以是泛型的 - 我们可以附加类型参数进而在别名定义右侧使用.
1 | type Container<T> = { value: T }; |
我们可以在属性定义中让别名引用自身:
1 | type Tree<T> = { |
结合聚合类型, 我们可以创造出相当烧脑的类型:
1 | type LinkedList<T> = T & { next: LinkedList<T> }; |
并不是说, 别名本身可以出现在定义右侧其他任何地方:
1 | type Yikes = Array<Yikes>; // error |
接口对比类型别名
我们提到过, 类型别名在某些方面与接口有相似之处, 然而, 两者还是存在一些细微差别.
第一个差别是, 接口创建在所有场合使用的新名字.
类型别名则不这样 — 举例来说, 错误信息不使用别名.
考察以下代码, 在编辑器中, 把鼠标悬浮在 interfaced
上方, 编辑器显示函数返回 Interface
, 把鼠标悬浮在 aliased
上方, 则显示函数返回对象字面量类型.
1 | type Alias = { num: number } |
在较老的 TypeScript 版本中, 类型别名既不能被继承/实现, 也不能继承/实现其他类型. 自 2.7 版开始, 你可以创建聚合类型来继承类型别名, 例如: type Cat = Animal & { purrs: true }
.
因为an ideal property of software is being open to extension(软件的理想属性是向扩展开放), 如果可行, 总是优先选用接口.
另一方面, 面对你无法用接口表达的形体, 而需要借助自适应类型和元组的时候, 类型别名便成为一种选择.
字符串字面量类型
字符串字面量类型允许你规定它的变量只能表示的定值.
实践中, 字符串字面量类型总与自适应类型, 类型护卫, 类型别名结合使用.
可以用它们模拟字符串枚举.
1 | type Easing = "ease-in" | "ease-out" | "ease-in-out"; |
你可以传递三个给定字符串中任意一个调用 animate, 传递其他字符串将会报错
1 | Argument of type '"uneasy"' is not assignable to parameter of type '"ease-in" | "ease-out" | "ease-in-out"' |
字符串字面量类型还可以用以区分函数重载:
1 | function createElement(tagName: "img"): HTMLImageElement; |
数值字面量类型
TypeScript 也有数值字面量类型.
1 | function rollDice(): 1 | 2 | 3 | 4 | 5 | 6 { |
它们应用范围不广, 多用于在缩小问题范围, 定位 bug 的场景:
1 | function foo(x: number) { |
换句话说, 在 x
与 2
比较时它必须是 1
, 以上检查造成了一个无效比较.
译注: 要么 x !== 1
为真, 整个表达式为真, 要么 x !== 1
为假, 继续求 x !== 2
的值, 而 x !== 1
为假说明 x === 1
, 第二个表达式必为真.
枚举成员类型
在枚举一章提过, 如果每个枚举成员都是用字面量初始化的, 枚举成员也有它们的类型.
在我们谈论单例类型(singleton types)的多数时候, 我们指的是枚举成员类型或字符串/数值字面量类型, 有很多用户会混用单例类型和字面量类型.
区辨联合
你可以组合单例类型, 自适应类型, 类型护卫, 和类型别名构造出一种所谓区辨联合的高级模式, 也可以叫标签联合, 或代数数据类型.
区辨联合广泛用于函数式编程.
有些语言自动为你区辨联合, 而 TypeScript 的区辨联合建立在现有 JavaScript 模式之上.
区辨联合有三种成分:
- 有一个共同单例类型属性的多个类型 — 区辨.
- 一个联合了
1
中所有类型的类型别名 — 联合. - 针对
1
中共同属性的类型护卫.
1 | interface Square { |
首先, 定义要参与联合的接口.
每个接口都有一个属于不同字符串字面量类型的 kind
属性.
这里的 kind
就叫做区辨或标签.
其他属性都是各个接口特有的.
注意, 到目前为止, 各个接口毫无关联.
下面, 将它们组成联合:
1 | type Shape = Square | Rectangle | Circle; |
使用区辨联合:
1 | function area(s: Shape) { |
完全覆盖检查
我们想让编译器告知我们没有覆盖区辨联合所有成员.
比如说, 当添加新类型 Triangle
到 Shape
中, 提醒我们同时更新 area
函数:
1 | type Shape = Square | Rectangle | Circle | Triangle; |
有两种方法可达成目的.
第一, 打开 --strictNullChecks
选项, 并指明函数返回值类型:
1 | function area(s: Shape): number { // error: returns number | undefined |
鉴于 switch
不再全面覆盖 Shape
, TypeScript 担心函数有机会返回 undefined
.
如果函数有显式返回值类型 number
, 它就会报错, 因为实际返回值类型是 number | undefined
.
不过, 这个方法过于隐晦, 除此之外, --strictNullChecks
不总是兼容旧代码.
第二种, 编译器利用 never
类型检查覆盖情况:
1 | function assertNever(x: never): never { |
这里, assertNever
检查 s
的类型是 never
— 所有其他事件都移除后剩下的类型.
如果你遗漏某个事件, s
获得实际类型, 你也得到一个类型错误.
这方法需要你定义一个新函数, 忘记定义就会很明显.
多态 this 类型
多态 this
类型代表所属类或接口的子类型.
我们称之为 F-有界多态性.
它让我们更容易表达分层流式接口.
以每个操作都返回 this
的简单计算器作为示例:
译注: 观察下例的返回值类型, 而不是函数体内的 this
.
1 | class BasicCalculator { |
因为类运用了 this
类型, 你可以继承该类, 新类不经修改就能沿用旧类的方法.
1 | class ScientificCalculator extends BasicCalculator { |
没有 this
类型, ScientificCalculator
便不能在继承 BasicCalculator
的同时保持流式接口.multiply
只能返回没有 sin
方法的 BasicCalculator
.
有了 this
类型, multiply
返回的 this
代表的是 ScientificCalculator
.
索引类型
结合索引类型, 你能使编译器检查运用动态属性名的代码.
举个例子, 大家熟知的一种 JavaScript 设计模式是从一个对象挑选属性集:
译注: 下面 propertyNames
类型是 string[]
.
1 | function pluck(o, propertyNames) { |
下面给出在 TypeScript 中实现这个和调用该函数的方法, 我们会用到索引类型查询, 索引访问运算符:
1 | function pluck<T, K extends keyof T>(o: T, propertyNames: K[]): T[K][] { |
编译器检查 manufacturer
和 model
的确是 Car
的属性.
这个例子引入了几个新类型操作符.
首先, keyof T
, 即索引类型查询运算符.
对任意类型 T
, keyof T
是 T
可知的, 公共属性名称的自适应类型.
举例如下:
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 | // error, 'unknown' is not in 'manufacturer' | 'model' | 'year' |
第二个运算符是 T[K]
, 索引访问运算符.
这里, 类型语法体现出表达式语法.
意思是 person['name']
的类型是 Person['name']
— 我们的例子: string
.
就如索引类型查询, 你可以在泛型语境使用 T[K]
, 这是它发挥真正实力的地方.
你要做的只是确保类型参数 K extends keyof T
.
再看另一个例子, getProperty
函数:
1 | function getProperty<T, K extends keyof T>(o: T, propertyName: K): T[K] { |
在 getProperty
中, o: T
和 propertyName: K
的含义是 o[propertyName]: T[K]
.
每当你返回结果 T[K]
, 编译器实例化该键的实际类型, 所以 getProperty
的返回值类型会随着请求的属性不同而变化.
1 | let name: string = getProperty(taxi, 'manufacturer'); |
索引类型和索引签名
如 keyof
和 T[K]
与索引签名相互作用. 索引签名的参数类型必须是 ‘string’ 或 ‘number’.
如果你的类型包含字符串型索引签名, keyof T
总返回 string | number
.
(不只是 string
, 因为在 JavaScript 中你可以通过字符串 (ojbect["42"]
) 或数字 (object[42]
) 来访问对象属性).
还有, T[string]
即索引签名的类型:
1 | interface Dictionary<T> { |
如果你的类型只有数值型索引签名, keyof T
只返回 number
.
1 | interface Dictionary<T> { |
映射类型
一个常见任务是把一个现有类型的每个属性改成”可选”的:
1 | interface PersonPartial { |
或是”只读”的:
1 | interface PersonReadonly { |
JavaScript 如此依赖此操作, 以至于 TypeScript 专门提供这种基于旧类型创建新类型的方法 — 映射类型.
类型映射按相同方式转换旧类型的每个属性以创建新类型.
比如, 你可以把所有属性转为 “只读” 的, 或 “可选” 的.
这里有几个例子:
笔记
1 | type Readonly<T> = { |
使用它们:
1 | type PersonPartial = Partial<Person>; |
注意, 该方法按整体(而不是逐成员)描述新类型.
如果你想增加成员, 可结合聚合类型:
1 | // Use this: |
下面, 我们来分析一个映射类型的各个部分:
1 | type Keys = 'option1' | 'option2'; |
单看语法, 有点像 for..in
用在索引签名中.
我们分三个部分理解:
- 类型参数
K
, 它依次绑定每个属性. - 字符串字面量自适应类型
Keys
, 包含要迭代的属性名. - 结果属性的类型.
在这个例子中, Keys
是一个硬编码的属性名列表, 属性的类型都是 boolean
, 这个映射类型等同于:
1 | type Flags = { |
对于实际应用, 就像上面的 Readonly
或 Partial
.
它们依靠某些现有类型, 以某种方式转换所有属性.
于是, 到了 keyof
与索引属性操作符(原文: indexed access types, 疑误)登场的时候:
1 | type NullablePerson = { [P in keyof Person]: Person[P] | null } |
把它们改写成泛型版本, 用途更广.
1 | type Nullable<T> = { [P in keyof T]: T[P] | null } |
概括一下, 它们的属性列表都是 keyof T
, 返回值类型是 T[P]
加上一些变化.
这是一个不错的用于表达任何泛型映射类型的模板.
由于这种形式的转换是homomorphic(同态)的, 意思是映射只应用在 T
的属性而不应用在别的什么东西上.
编译器知道它可以在添加新修饰符之前拷贝所有现有属性修饰符.
例如, 如果 Person.name
是 “只读” 的, Partial<Person>.name
就会是 “只读” 和 “可选” 的.
下个例子, T[P]
包裹在 Proxy<T>
类中:
1 | type Proxy<T> = { |
Readonly<T>
和 Partial<T>
是如此有用, 顺理成章地, 它们已经随 Pick
和 Record
一并包含于 TypeScript 标准库.
1 | type Pick<T, K extends keyof T> = { |
Readonly
, Partial
, Pick
都是同态的, 而 Record
却不然.
一个 Record
非同态的迹象是它不从一个输入类型拷贝属性:
1 | type ThreeStringProps = Record<'prop1' | 'prop2' | 'prop3', string> |
从根本上, 非同态类型创建新的属性, 也没有属性修饰符可拷贝.
从映射类型推断
你已经学会了如何包裹类型的属性, 但是还不知道怎么解开它们.
好在, 它并不难:
1 | function unproxify<T>(t: Proxify<T>): T { |
注意解包推断只接受同态映射类型.
如果一个映射类型不是同态的, 你必须为解包函数指定显式类型参数.
条件类型
TypeScript 2.8 引入条件类型, 为语言增加了表达非统一类型映射的能力.
条件类型根据以类型关系测试表达的条件从两个候选类型中选择一个.
1 | T extends U ? X : Y |
上例表示如果 T
能赋值给 U
, 类型就是 X
, 否则类型是 Y
.
条件类型 T extends U ? X : Y
可以解析成 X
和 Y
二者之一, 如果条件依赖一个或多个类型参数, 解析或被推迟.
当 T
或 U
包含类型参数时, 解析结果(X
, Y
, 或推迟)取决于类型系统是否掌握足够信息推断 T
总是能赋值给 U
.
以下是一个类型可以被立即解析的例子:
1 | declare function f<T extends boolean>(x: T): T extends true ? string : number; |
另一个例子, TypeName
是一个类型别名, 其采用了条件类型的嵌套:
1 | type TypeName<T> = |
以下是一个条件类型被推迟的例子 - 条件类型不选择分支, 维持原状:
1 | interface Foo { |
上例, 条件类型变量 a
尚未选择一个分支.
当其他代码片段调用 foo
, 某个不同的类型会替代 U
, 于是, TypeScript 对条件类型重新解析, 以决定能否选择一个具体分支.
同时, 条件类型能赋值给它以外的类型, 只要条件类型的每个分支对目标类型都是可赋值的.
我们能把 U extends Foo ? string : number
赋值给 string | number
的原因即是如此, 无论条件类型 U extends Foo ? string : number
在何时解析, 结果类型只有 string
和 number
两种选择.
分配式条件类型
测试类型是 “裸” 类型参数的条件类型叫做分配式条件类型.
分配式条件类型在具化时自动依照自适应类型展开.
举例说明, 用类型参数 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 | type T10 = TypeName<string | (() => void)>; // "string" | "function" |
具化分配式条件类型 T extends U ? X : Y
, 条件类型中每个 T
都分配到一个自适应类型成分 (条件类型依照自适应类型展开后, 每个 T
对应自适应类型的一个成分).
另外, X
中对 T
的引用都有附加参数限制 U
(即: 在 X
中认为 T
对 U
是可赋值的).
实例
1 | type BoxedValue<T> = { value: T }; |
注意到在 Boxed<T>
“真” 分支中, T
有附加限制 any[]
, 从而可推出数组元素类型是 T[number]
. 最后一个条件类型的展开情况也很有代表性.
条件类型的分配式特征能过滤自适应类型:
笔记
1 | type Diff<T, U> = T extends U ? never : T; // Remove types from T that are assignable to U |
条件类型和映射类型结合后更是有用:
笔记
1 | type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T]; |
如同自适应类型, 聚合类型, 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 | type Unpacked<T> = |
笔记
下例表明同一个类型参数处于协变位的多个引用能推导出自适应类型:
1 | type Foo<T> = T extends { a: infer U, b: infer U } ? U : never; |
类似地, 同一个类型参数处于逆变位的多个引用能推导出聚合类型:
1 | type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never; |
推导有多个调用签名的类型 (如重载函数), 推导从最后一个签名开始 (大体上说: 最宽松的那一个).
基于参数类型的列表来解析重载不可能的.
1 | declare function foo(x: string): number; |
不能在类型参数限制子句中使用 infer
声明:
1 | type ReturnType<T extends (...args: any[]) => infer R> = R; // Error, not supported |
要达成相同效果, 从限制子句移除类型参数, 采用条件类型:
1 | type AnyFunction = (...args: any[]) => any; |
内建条件类型
TypeScript 2.8 在 lib.d.ts
中添加了一批内建条件类型:
Exclude<T, U>
— 从T
中排除能赋值给U
的类型.Extract<T, U>
— 从T
中提取能赋值给U
的类型.NonNullable<T>
— 从T
中所有null
和undefined
.ReturnType<T>
— 取得函数T
的返回值类型.InstanceType<T>
— 取得构造器函数T
的实例类型.
实例
1 | type T00 = Exclude<"a" | "b" | "c" | "d", "a" | "c" | "f">; // "b" | "d" |
注:
Exclude
是在这里建议的Diff
类型的一个实现. 我们采用命名Exclude
是为了不破坏已经定义了Diff
的现有代码, 而且我们觉得那个名字能更好地传达该类型语义.