..

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 的同名 Type
  • new 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可以给变量加上 类型符号 来限制变量的类型。但是这样写又太麻烦了,所以一般只有如下几种情况会去显示标明类型:

  1. 写数据(model)定义、接口定义的时候;
  2. 变量先声明,后初始化的时候;
  3. TS 自动推导出的类型,只是其中一种情况,需要开发者显式标明出所有允许的类型;
  4. 函数的参数必须显式标明类型(同情况1),但是返回类型一般也无需显式标明,除非:
    1. 声明接口时,因为这时候没有面量上下文可供TS推导;
    2. 函数内部的分支多、实现逻辑复杂时,显示标明返回类型,免得自己写错逻辑;

其余情况下基本是让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根据面量上下文推导的时候,大概有两种推导方式:

  1. let推导,宽泛推导,即尽量往更宽的范围去根据面量值来推导类型。例如name1、obj1、obj2、arr1的推导方式,
  2. const推导,精准推导,基本你写了什么它就是什么类型。例如name2、name3、obj3、arr2的推导方式

对于复合数据结构Array、Object,无论是用const还是let声明的变量:

  1. 其key的推导只有const推导;
  2. 其value的推导默认是let推导,可以用as const显示采用const推导;

一般情况下我们都不用在意它是let推导还是const推导,基本是无感知的。因为一个const变量,没有本来就没有变的可能,而得益于类型兼容,name1 = name2这样的赋值又是完全合法的。

泛型的推导

在使用泛型的时候,TS也会基于面量上下文进行推导。举个例子,我们来实现一个简陋版的 lodash.keyBykeyBy 的经典用法是: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 参数里推导出了 TStudent

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等知识点。