TypeScript的类型推导与简单转换
今天主要讲下TypeScript的类型推导,以及简单的类型体操,这里不会涉及详细的语法说明。这里假定你日常多少使用过 TypeScript,或者阅读过官网的文档。
Type与Value之间的界线
TS 经过编译之后就是 JS 代码。也就是TS所附加的所有 类型(Type) 都会被清除,只留下 “值(Value)”。这里的 Value 是一个很广义的概念,大概可以说属于JS的都叫值,无论是原始值、还是对象、函数、JS关键字等等。
可能是因为 TS 的类型操作符、基础类型与 JS 里的符号类似,导致二者容易混用。通过介绍 TS 的各种类型,指出二者之间容易混淆的部分,从而把二者的关系理清楚。
基本类型
与 JS 的值类型相对应,TS 有 number、string、boolean等原始值类型,也有数组、class等组合类型。
let age: number = 3;
let name: string = 'Yi';
let enable: boolean = true;
let friends: string[] = ['Lili', 'Wang Ming', 'Jake'];
class People {}
let p1: People = new People();
面量类型
除了抽象的类型,TS 还有比较新颖的面量类型。下面的例子只是演示,实际不会这么写。
let age: 17 = 3; // 报错,TS2322: Type '3' is not assignable to type '17'.
let name: 'Yi' = 'Bin'; // 报错,TS2322: Type '"Bin"' is not assignable to type '"Yi"'.
let enalbe: false = true; // 报错,TS2322: Type 'true' is not assignable to type 'false'.
这里所谓面量类型,也可以简单的理解为具体值。但是记住,放在类型符号 :
后面的它们是 Type,而不是 Value。这些 Type 对应的符号,放在 JS 里又是完全合法的值。这是容易把 TS 的 Type 与 Value 混淆在一起的一个地方。
组合类型
除了基础类型与面量类型,TS 还有高级一点的组合类型。
// 符号 '|' 把多个类型组合成一个"联合类型"
let encoding: 'utf-8' | 'utf-16' | 'utf-32' | null = null;
interface Dog {
weight: number;
}
interface Flyable {
flyTo: (target: string) => {}
}
// 符号 '&' 把多个类型合并在一起。 flyDog 这个变量的类型,既要符合 Dog
// 也要符合 Flyable。
let flyDog: Dog & Flyable = {
weight: 19,
flyTo: (target) => {
console.log('Fly dog~~~~~~~~~~~');
}
}
符号 |
与 &
在 JS 里分别表示位运算"或"跟"与",而在 TS 的类型系统里,它有别的含义。除此之外,还有一起其它的关键字。简单总结一下:
A | B
表示联合类型,而 JS 里是位运算“或”A & B
表示合并两个类型,而 JS 里是位运算“与”T extends B
表示限定泛型参数A必须兼容类型B,或者是 interface 继承。而JS里, extends 是 class 继承的关键字。typeof A
在 TS 里是从 A 这个 Value 里提取其对应的 Type,而 JS 里是返回一直值的字符串类型描述
class 类型
在TS里,Type 跟 Value 有很严格的分界线,二者不可混用。只不过为了方便,TS 给一些 Value 默认赋予了同名的Type。如,前面提到的面量原始值,其 Value 与对应的 Type 类型符号一样。再比如,ES6 里的 class:
class A {
public name = 'Harry';
}
// 冒号后面的 A 是合法的 js class,但是它在这里表示的是 Type 而不是 Value
// 它是由 TS 自动生成的同名 Type。
let a: A = new A();
上面的例子中,let a: A = new A()
两个 A 的含义不一样:
let a: A
中的 A 是由 TS 自动生成的 class A 的同名 Typenew A()
中的 A 是 js 里的 class。按我们开头的说法,它是一个 “Value”
当不存在同名 Type 与 Value 的时候,你就能清晰的“看到”这条分界线,举两个例子:
// 例子一:尝试把 Value 当 Type 使用
function hello(name: string) {
return `Hello, ${name}`;
}
// 我们希望再写一个箭头函数,使其类型与 hello 一致
let b: hello = (name: string) => { // 报错:TS2749: 'hello' refers to a value, but is being used as a type here. Did you mean 'typeof hello'?
return `Hi, ${name}`;
};
// 例子二:尝试把 Type 当 Value 使用
// 复用上面的 class A 定义,把它放在文件 A.ts 里,然后在另一个文件里用 import type 只导入它的类型
import type {A} from './A';
// TS1361: 'A' cannot be used as a value because it was imported using 'import type'.
// 因为这个文件只导入了 class A 的 Type,而 `new A()` 是 Value 的范畴。
let a = new A();
TS 并不会为函数生成同名 Type,所以例一报错了。从其报错提示就可以知道,我们可以用 typeof Value
来提取这个 Value 的 Type。所以例一改成 let b: typeof hello
即可。但是反过来不行,TS 没有提供根据 Type 来生成 Value 的方法。
什么时候使用类型标记?
类似早期的静态强类型语言,TS可以给变量加上 类型符号 来限制变量的类型。但是这样写又太麻烦了,所以一般只有如下几种情况会去显示标明类型:
- 写数据(model)定义、接口定义的时候;
- 变量先声明,后初始化的时候;
- TS 自动推导出的类型,只是其中一种情况,需要开发者显式标明出所有允许的类型;
- 函数的参数必须显式标明类型(同情况1),但是返回类型一般也无需显式标明,除非:
- 声明接口时,因为这时候没有面量上下文可供TS推导;
- 函数内部的分支多、实现逻辑复杂时,显示标明返回类型,免得自己写错逻辑;
其余情况下基本是让TS自己基于面量上下文自动做类型推导。
类型推导
TS会根据面量上下文自动推导出变量的类型、函数的返回类型。就像下面这样:
const 推导
const 推导原始值
先来看看一段自动推导类型的代码:
let name1 = 'Harry'; // 自动推导为string类型
name1
这个变量虽然没给类型说明,但是 TS 根据初始化的值会推导出它是一个 string
。这种推导是宽泛的。我们再来看看另一种推导方式:
const name2 = 'Harry'; // name2的类型自动推导为 'Harry'
跟 let name1
不同的是,const name2
推导出来的类型却是面量类型 'Harry'
。这其实也很合理,因为 name2
是用 const
声明的常量,不可变。所以它从始至终都只能是 'Harry'
这个常量字符串。可以把这种推导叫做 “const 推导”。
也可以通过 as const
关键字强制 TS 使用这种推导方式来推导变量的类型。就像这样:
let name3 = 'Harry' as const; // 同样推导为 'Harry'
上面的代码只是作为演示使用,正常情况下这样的代码没有意义。
const 推导非原始值
上面的例子是 const 推导作用在原始值上的情况,作用在非原始值的时候,情况就有点不太一样。我来看看 TS 如何推导对象的类型的:
let obj1 = { // obj1 类型是 {name: string; age: number;}
name: 'Harry',
age: 17,
};
const obj2 = { // obj2 类型也是 {name: string; age: number;}
name: 'Harry',
age: 17,
};
可以看到,自动推导作用在 {name: 'Harry', age: 17};
这样的非原始值的时候,无论是用 let
还是用 const
声明变量,自动推导的类型都是一样的。
这样的推导是比较符合 JS 的使用预期的。因为 const obj2
这样声明出来的变量,只是变量的引用不能变,即只能指向声明初始化的对象。但是,这个对象本身的值是可以变的。比如,声明完之后,我们依然可以修改 obj2
对象的下的属性,就像这样:obj2.age = 19
。如果你从其它静态语言的世界来到 JS 的世界,可能会觉得这种行为很不符合直觉。但是,这就是动态 JS 的行为。
如果我们想要让 值 {name: 'Harry', age: 17};
自动推导为 类型 {name: 'Harry'; age: 17;}
,可以是用 as const
。就像这样:
let obj3 = { // 类型为:{name: 'Harry'; age: 17;}
name: 'Harry',
age: 17,
} as const;
as const
用在数组上面,就会推导出 tuple 类型,就像这样:
// arr1推导为 (string | number)[];
const arr1 = ['Harry', 17];
// 而arr2则推导为 readonly ['Harry', 17],这是tuple类型
const arr2 = ['Harry', 17] as const;
总结
TS根据面量上下文推导的时候,大概有两种推导方式:
- let推导,宽泛推导,即尽量往更宽的范围去根据面量值来推导类型。例如name1、obj1、obj2、arr1的推导方式,
- const推导,精准推导,基本你写了什么它就是什么类型。例如name2、name3、obj3、arr2的推导方式
对于复合数据结构Array、Object,无论是用const还是let声明的变量:
- 其key的推导只有const推导;
- 其value的推导默认是let推导,可以用as const显示采用const推导;
一般情况下我们都不用在意它是let推导还是const推导,基本是无感知的。因为一个const变量,没有本来就没有变的可能,而得益于类型兼容,name1 = name2
这样的赋值又是完全合法的。
泛型的推导
在使用泛型的时候,TS也会基于面量上下文进行推导。举个例子,我们来实现一个简陋版的 lodash.keyBy
。keyBy
的经典用法是:lodash.keyBy([{id: 1, name: 'Harry'}, {id: 2, name: 'Lili'}], 'id')
,这样就可以得到一个以 id 为 key 的 mapping。
function myKeyBy<T>(arr: T[], key: string) {
const mapping: Record<string, T> = {};
for (const item of arr) {
const keyVal = (item as any)[key];
mapping[keyVal] = item;
}
return mapping;
}
interface Student {
id: number;
name: string;
}
const students: Student[] = [{id: 1, name: 'Harry'}, {id: 2, name: 'Close'}];
const mapping = myKeyBy(students, 'name');
mapping.Close!; // 类型是 Student
上面的代码中,我们声明 myKeyBy
的时候,没有给范型参数 T
一个默认值,在调用 myKyeBy(students, 'name')
的时候也没有传入一个范型参数。但是 TS 并没有报错,因为它从传入的 students
参数里推导出了 T
是 Student
。
Narrowing
上面的推导都算是静态推导,就是根据已有的代码上下文进行推导。除此之外,TS 还支持简单的动态推导。就是根据可信的判断做排除法,TS称这个过程为 Narrowing。这些可信的判断,TS称之为 predicate。思考下t1 ~ t5分别是什么类型:
function printVal(val: number | string | boolean | null) {
if (val === null) {
// t1, 此处val是什么类型?
} else if (typeof val === 'number') {
// t2, 这里的val是类类型?
} else if (typeof val === 'string') {
// t3
} else if (typeof val === 'boolean') {
// t4
} else {
// t5
}
}
上面代码的可信判断,就是 js 里的 typeof
与 ===
,这两个是比较常用操作符。除此之外,还有 in、instanceof、switch...case
等。这些都是自带的 predicate,你也可以实现一个自己的 predicate,例如:
function isNil(val: any): val is (null | undefined) {
// predicate函数的返回值必须是boolean
return val === null || val === undefined;
}
let a: null | string;
a.to
if (!isNil(a)) {
a = a.toLowerCase(); // TS能通过!isNil(a)判断出,a在这里一定是string
}
这里举两个跟Narrowing相关的经典例子。
例子一:渲染聊天信息
// 用Enum定义出聊天消息的类型,方便写TS Narrowing,以及方便后续使用
enum MessageType {
Text = 'text',
Image = 'image',
Video = 'video',
Voice = 'voice'
}
// 每种消息类型都带有一个type字段,也是为了方便TS Narrowing,然后将他们联合成一个新类型Message
type Message = TextMessage | ImageMessage | VideoMessage | VoiceMessage;
interface TextMessage {
type: MessageType.Text;
summary: string;
markdown_text: string;
}
interface ImageMessage {
type: MessageType.Image;
thumbnail_url: string;
url: string;
width: number;
height: number;
}
interface VideoMessage {
type: MessageType.Video;
video_url: string;
duration: number;
cover_image_url: string;
width: number;
height: number;
}
interface VoiceMessage {
type: MessageType.Voice;
voice_url: string;
duration: number;
}
function renderMessageItem(item: Message) {
switch (item.type) {
case MessageType.Text:
// message的类型是MessageText
// 后续的分支以此类推
item.markdown_text
break;
case MessageType.Image:
item.thumbnail_url
break;
case MessageType.Video:
break;
case MessageType.Voice:
break;
default:
const _: never = item;
break;
}
}
每种message都加个type字段的做法并不是必须的,你也可以不要这个字段,然后把switch换成几个if-else,用in来判断。只是这样做会很费力。
例子二:实现一个类型完备的Pub/Sub
先描述出这个Pub/Sub的样子:
class PubSub {
static publish(eventName: string, eventData: any) {
}
static subscribe(eventName: string, callback: (eventData: any) => void) {
}
}
这样的一个Pub/Sub,对eventName、eventData的约束非常的小,随着时间的推移,就很容易出现下面这种不自信的代码:
// publish的时候,你没办法直观的知道要publish一个什么样的eventData,这时候就需要去搜索之前用到这个事件的例子
// PubSub.publish('REPLY_CREATED',
// subscribe的时候,要小心翼翼,写一堆防御代码
PubSub.subscribe('REPLY_CREATED', (data?: {reply?: Reply}) => {
if (
data &&
data.reply &&
data.reply.id
) {
// 安全了~
}
});
我们可以用函数重载来实现类型的完备:
// 同样,我们也给eventName定义一个Enum
enum PubSubEvents {
AppLaunched = 'AppLaunched',
FieldValueChanged = 'FieldValueChanged',
}
// 再定义下EventData的类型
interface AppLaunchedEvent {
open_source: string;
}
interface FieldValueChangedEvent {
name: string;
oldVal: any;
newVal: any;
source: 'init' | 'user-input' | 'user-press-clear';
}
class PubSubV2 {
// 然后用函数重载,把eventName跟对应的EventData类型一一对应起来,就好像在写switch语句
static publish(eventName: PubSubEvents.AppLaunched, eventData: AppLaunchedEvent): void;
static publish(eventName: PubSubEvents.FieldValueChanged, eventData: FieldValueChangedEvent): void;
static publish(eventName: PubSubEvents, eventData: any): void {
}
static subscribe(eventName: PubSubEvents.AppLaunched, callback: (eventData: AppLaunchedEvent) => void): void;
static subscribe(eventName: PubSubEvents.FieldValueChanged, callback: (eventData: FieldValueChangedEvent) => void): void;
static subscribe(eventName: PubSubEvents, callback: (eventData: any) => void): void {
}
}
// publish事件的时候,如果乱写event data,会被TS拦住
// TS报错:'open_source__' does not exist in type 'AppLaunchedEvent'. Did you mean to write 'open_source'?
PubSubV2.publish(PubSubEvents.AppLaunched, {
open_source__: 'push_notification_3200',
});
// TS报错:Argument of type 'null' is not assignable to parameter of type 'AppLaunchedEvent'.
PubSubV2.publish(PubSubEvents.AppLaunched, null);
// 订阅事件的时候,TS会根据eventName自动推导出eventData,不需要自己去加类型说明
PubSubV2.subscribe(PubSubEvents.AppLaunched, (event) => {
console.log(event.open_source);
});
从面量对象提取对象keys的类型 – keyof运算符
这个很简单,也很常用。就是用 keyof 运算符 来提取面量对象的类型的 peoperties。
const size2Source = {
16: require('./16.png'),
24: require('./24.png'),
32: require('./32.png'),
};
interface IconPencilProps {
tintColor?: string;
size: keyof (typeof size2Source); // size的类型就是 16 | 24 | 32,是一个Union Type。这里的括号仅为了阅读方便,可以省略。
}
const a: IconPencilProps = {
size: 64, // 报错: TS2322: Type '64' is not assignable to type '16 | 24 | 32'.
};
往后当这个组件支持新尺寸的时候,只需要修改size2Source,对应的衍生类型都会自动更新。
从面量对象提取对象values的类型 – Indexed Access Types
TS同样可以从面量对象提某个值,或者某些值的类型。TS称之为Indexed Access Types。其方式类似于JS里访问一个对象的某个key:
const friend = {
name: 'Harry',
age: 17,
married: false,
};
// JS里通过点或者中括号来访问某个key,二者等价!
console.log(friend.name);
console.log(friend['name']);
// TS也可以访问某个Type的某个Key的类型,但是不支持点语法。
type Friend = typeof friend;
type FriendName = Friend['name'];
// 很显然不支持点操作是有原因的:
type FriendNameOrAge = Friend['name' | 'age']; // 类型为:string | number
type FriendValues = Friend['name' | 'age' | 'married']; // 类型为:string | boolean | number
type FriendKeys = keyof Friend; // 类型为:'name' | 'age' | 'married'
type FriendValuesV2 = Friend[FriendKeys]; // 类型为:string | boolean | number
// 这个语法对Array、Tuple同样适用
const queryKeys = ['page_source', 'ut', 'utm_source', 'media'];
type QueryKeys = typeof queryKeys;
type FirstQuery = QueryKeys[0]; // 类型是string
type SecondQuery = QueryKeys[1]; // 类型依然是string
const queryKeys2 = ['page_source', 'ut', 'utm_source', 'media'] as const;
type QueryKeys2 = typeof queryKeys2;
type FirstQuery2 = QueryKeys2[0]; // 类型是'page_source'
type SecondQuery2 = QueryKeys2[1]; // 类型是'ut'
type AllQuery2Values = QueryKeys2[number]; // 类型是 'page_source' | 'ut' | 'utm_source' | 'media'
Mapped Types
我们已经学会提取对象的 keys 跟 values 的类型的方法,现在让我们通过 keys 跟 values 再衍生出另一个 Friend 类型:
const friend = {
name: 'Harry',
age: 17,
married: false,
};
type Friend = typeof friend;
type FriendKeys = keyof Friend;
type FriendValues = Friend[FriendKeys];
// 衍生出新类型 Friend2
type Friend2 = {
[k in FriendKeys]: Friend[k];
}
// Friend2 完全等价于 Friend
const a: Friend2 = friend;
TS把Friend2的定义方式(类型)称之为 Mappend Types。把它的结构用伪代码表示就好理解了:
type Friend2;
for (const k in FriendKeys) {
Friend2[key] = Friend[k]
}
上面的伪代码跟JS中经典的复制对象代码很像,所谓的 Mappend Types 也就是这么一回事。上面的演示当然有点没有意义。一般使用 Mapped Types 会对要衍生的类型进行一些修饰。比如,过滤 key、映射 key、把 key 变成 readonly 等。通过两个例子来说明一下。
第一个例子,实现一个 ReadOnly<T>
工具类型:
type MYReadOnly<T> = {
readonly [k in keyof T]: T[k];
}
const friend = {
name: 'Harry',
age: 17,
married: false,
};
type ReadOnlyFriend = MyReadOnly<typeof friend>;
// ReadOnlyFriend 的类型就变成了:
// {
// readonly name: string;
// readonly age: number;
// readonly married: boolean;
// }
第二个例子,实现一个 Omit
:
type MyOmit<T, OmitKeys> = {
[k in keyof T as k extends OmitKeys ? never : k]: T[k];
}
const friend = {
id: 3,
name: 'Harry',
age: 17,
};
type CreateFriendForm = MyOmit<typeof friend, 'id'>;
// id 这个字段被剔除了,所以 CreateFriendForm 的类型就变成了:
// {
// name: string;
// age: number;
// }
类型工具
上面讲了TS的类型、以及对象类型的几个基本操作,有了这两个概念之后,再结合TS提供的一些工具类型,我们就可以做一些简单的类型变换。
比较有用的工具类型:
ReturnType<Function>
与Parameters<Function>
,用来提取函数的返回值类型与参数类型。如果还在用redux(最好别用),可以用ReturnType来获取connect redux所得到的props类型,类似这样:type Props = OwnProps & ReturnType<typeof mapStateToProps> & ReturnType<typeof mapDispatchToProps>;
Pick<Type, Keys>
与Omit<Type, Keys>
,用来提取或者剔除某个类型的key。比如,可以用Pick从Entry衍生出对应的创建表单的类型:Pick<Topic,'title' | 'content' | 'group~id~'>,或者Omit<Topic, 'id' | 'time~created~' | 'time~removed~' | 'time~modified~'>
Partial<Type>
与Required<Type>
,用来加上或者移除optional修饰符。比如,可以用Partial从Entry衍生出对应的修改表单的类型:Partial<Topic,'title' | 'content'> & Pick<Topic, 'id'>
Extract<Type, Union>
与Exclude<UnionType, ExcludedMembers>
,用来提取或者过滤类型,主要是Union Type。Readonly<Type>
,用来给每个属性加上readonly修饰符
掌握到这里就差不多打住了!如果你不是要写TS库函数,大部分时候也就够用了。如果你用的框架比较OOP,比如Angular、nestjs,那可以再深入了解class、decorator、ThisType等知识点。