Typescript类型系统小tips

本文属转载,文脚注有来源

2.1 "鸭子"类型

"鸭子"类型??(黑人问号), 第一次看到这名词我也很懵逼, 其实它说的是结构型类型,而目前类型检测主要分为结构型(structural)类型以及名义型(nominal)类型。

interface Point2D {
  x: number;
  y: number;
}
interface Point3D {
  x: number;
  y: number;
  z: number;
}
var point2D: Point2D = { x:0, y: 10}
var point3D: Point3D = { x: 0, y: 10, z: 20}

function iTakePoint2D(point: Point2D) { /*do sth*/ }

iTakePoint2D(point2D); // 类型匹配
iTakePoint2D(point3D); // 类型兼容,结构类型
iTakePoint2D({ x:0 }); // 错误: missing information `y`

区别

  • 结构型类型中的类型检测和判断的依据是类型的结构,会看它有哪些属性,分别是什么类型;而不是类型的名称或者类型的id。

  • 名义类型是静态语言Java、C等语言所使用的,简单来说就是,如果两个类型的类型名不同,那么这两个类型就是不同的类型了,尽管两个类型是相同的结构。

  • Typescript中的类型是结构型类型,类型检查关注的是值的形状,即鸭子类型duck typing, 而且一般通过interface定义类型,其实就是定义形状与约束~ 所以定义interface其实是针对结构来定义新类型。对于Typescript来说,两个类型只要结构相同,那么它们就是同样的类型。

2.2 类型判断/区分类型

知道了typescript是个'鸭子类型'后,我们就会想到一个问题,ts这种鸭子类型怎么判断类型啊,比如下面这个例子:

  public convertString2Image(customizeData: UserDataType) {
    if (Helper.isUserData(customizeData)) {
      const errorIcon = searchImageByName(this.iconImage, statusIconKey);
      if (errorIcon) {
        (customizeData as UserData).title.icon = errorIcon;
      }
    } else if (Helper.isUserFloorData(customizeData)) {
      // do nothing
    } else {
      // UserAlertData
      let targetImg;
      const titleIcon = (customizeData as UserAlertData)!.title.icon;
      if (targetImg) {
        (customizeData as UserAlertData).title.icon = targetImg;
      }
    }
    return customizeData;
  }

该方法是根据传入的用户数据来将传入的icon字段用实际对应的图片填充,customizeData是用户数据,此时我们需要根据不同类型来调用searchImageByName方法去加载对应的图片,所以我们此时需要通过一些类型判断的方法在运行时判断出该对象的类型。

基础的类型判断

基本的类型判断方法我们可能会想到typeofinstanceof,在ts中,其实也可以使用这两个操作符来判断类型,比如:

  • 使用typeof判断类型

function doSomething(x: number | string) {
  if(typeof x === 'string') {
      console.log(x.toFixed()); // Property 'toFixed' does not exist on type 'string'
      console.log(x.substr(1));
  } else if (typeof x === 'number') {
      console.log(x.toFixed());
      console.log(x.substr(1)); // Property 'substr' does not exist on type 'number'.
  }
}

可以看到使用typeof在运行时判断基础数据类型是可行的,可以在不同的条件块中针对不同的类型执行不同的业务逻辑,但是对于Class或者Interface定义的非基础类型,就必须考虑其他方式了。

  • 使用instanceof判断类型 下面这个例子根据传入的geo对象的类型执行不同的处理逻辑:
  public addTo(geo: IMap | IArea | Marker) {
    this.gisObj = geo;
    this.container = this.draw()!;
    if (!this.container) {
      return;
    }
    this.mapContainer.appendChild<HTMLDivElement>(this.container!);
    if (this.gisObj instanceof IMap) {
      this.handleDuration();
    } else if(this.gisObj instanceof Marker) {
      //
    }
  }

可以看到,使用instanceof动态地判断类型是可行的,而且类型可以是Class关键字声明的类型,这些类型都拥有复杂的结构,而且拥有构造函数。总地来说,使用instanceof判断类型的两个条件是:

  1. 必须是拥有构造函数的类型,比如类类型。
  2. 构造函数prototype属性类型不能为any

利用类型谓词来判断类型 结合一开始的例子,我们要去判断一个鸭子类型,在ts中,我们有特殊的方式,就是类型谓词(type predicate)的概念,这是typescript的类型保护机制,它会在运行时检查确保在特定作用域内的类型。针对那些Interface定义的类型以及映射出来的类型,而且它并不具有构造函数,所以我们需要自己去定义该类型的检查方法,通常也被称为类型保护

例子中的调用的两个基于类型保护的方法的实现

  public static isUserData(userData: UserDataType): userData is UserData {
    return ((userData as UserData).title !== undefined) && ((userData as UserData).subTitle !== undefined)
      && ((userData as UserData).body !== undefined) && ((userData as UserData).type === USER_DATA_TYPE.USER_DATA);
  }
  public static isUserFloorData(userFloorData: UserDataType): userFloorData is UserFloorData {
    return ((userFloorData as UserFloorData).deviceAllNum !== undefined)
      && ((userFloorData as UserFloorData).deviceNormalNum !== undefined)
      && ((userFloorData as UserFloorData).deviceFaultNum !== undefined)
      && ((userFloorData as UserFloorData).deviceOfflineNum !== undefined);
  }

实际上,我们要去判断这个类型的结构,这也是为什么ts的类型系统被称为鸭子类型,我们需要遍历对象的每一个属性来区分类型。换句话说,如果定义了两个结构完全相同的类型,即便类型名不同也会判断为相同的类型~

2.3 索引类型干嘛用?

索引类型(index types),使用索引类型,编译器就能够检查使用了动态属性名的代码。ts中通过索引访问操作符keyof获取类型中的属性名,比如下面的例子:

function pluck<T, K extends keyof T>(o: T, names: K[]): T[K][] {
    return names.map(n => o[n]);
}

interface Person {
  name: string;
  age: number;
}
let person: Person {
  name: 'Jarid',
  age: 35
}
let strings: string[] = pluck(person, ['name']);

原理 编译器会检查name是否真的为person的一个属性,然后keyof T,索引类型查询操作符,对于任何类型T, keyof T的结果为T上已知的属性名的联合。

let personProps: keyof Person; // 'name' | 'age'

也就是说,属性名也可以是任意的interface类型!

索引访问操作符T[K]

索引类型指的其实ts中的属性可以是动态类型,在运行时求值时才知道类型。你可以在普通的上下文中使用T[K]类型,只需要确保K extends keyof T即可,例如下面:

function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
    return o[name];
}

原理:o:Tname:K 表示o[name]: T[K] 当你返回T[K] 的结果,编译器会实例化key的真实类型,因此getProperty的返回值的类型会随着你需要的属性改变而改变。

let name: string = getProperty(person, 'name');
let age: number = getProperty(person, 'age');
let unknown = getProperty(person, 'unknown'); // error, 'unknown' is not in 'name' | 'age'

索引类型和字符串索引签名 keyofT[k] 与字符串索引签名进行交互。 比如:

interface Map<T> {
    [key: string]: T; // 这是一个带有字符串索引签名的类型, keyof T 是 string
}
let keys: keyof Map<number>; // string
let value: Map<number>['foo']; // number

Map<T>是一个带有字符串索引签名的类型,那么keyof T 会是string。

2.4 映射类型

背景 在使用typescript时,会有一个问题我们是绕不开的 --> 如何从旧的类型中创建新类型即映射类型。

interface PersonPartial {
    name?: string;
    age?: number;
}

interface PersonReadonly {
    readonly name: string;
    readonly age: number;
}

可以看到PersonReadOnly这个类型仅仅是对PersonParial类型的字段只读化设置,想象一下 如果这个类型是10个字段那就需要重复写这10个字段。我们有没办法不去重复写这种样板代码,而是通过映射得到新类型? 答案就是映射类型,

映射类型的原理 新类型以相同的形式去转换旧类型里每个属性:

type Readonly<T> {
   readonly [P in keyof T]: T[P];
}

它的语法类似于索引签名的语法,有三个步骤:

  1. 类型变量K, 依次绑定到每个属性。
  2. 字符串字面量联合的Keys,包含了要迭代的属性名的集合
  3. 属性的类型。

比如下面这个例子

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

Keys,是硬编码的一串属性名,然后这个属性的类型是boolean,因此这个映射类型等同于:

type Flags = {
    option1: boolean;
    option2: boolean;
}

典型用法 我们经常会遇到的或者更通用的是(泛型的写法):

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

声明一个Person类型,一旦用Nullable类型转换后,得到的新类型的每一个属性就是允许为null的类型了。


// test
interface Person {
    name: string;
    age: number;
    greatOrNot: boolean;
}
type NullPerson = Nullable<Person>;

const nullPerson: NullPerson = {
    name: '123',
    age: null,
    greatOrNot: true,
};

骚操作 利用类型映射,我们可以做到对类型的PickOmitPick是ts自带的类型,比如下面的例子:

export interface Product {
  id: string;
  name: string;
  price: string;
  description: string;
  author: string;
  authorLink: string;
}

export type ProductPhotoProps = Pick<Product, 'id' | 'author'| 'authorlink' | 'price'>;

// Omit的实现
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

export type ProductPhotoOtherProps = Omit<Product, 'name' | 'description'>;

我们可以把已有的Product类型中的若干类型pick出来组成一个新类型;也可以把若干的类型忽略掉,把剩余的属性组成新的类型。

好处

  • keyof T返回的是T的属性列表,T[P]是结果类型,这种类型转换不会应用到原型链上的其他属性,意味着映射只会应用到T的属性上而不会在原型链的其他属性上。编译器会在添加新属性之前拷贝所有存在的属性修饰符。
  • 不管是属性或者方法都可以被映射。

2.5 Never类型 vs Void类型

never 首先,never类型有两种场景:

  • 作为函数返回值时是表示永远不会有返回值的函数。
  • 表示一个总是抛出错误的函数。
// 返回never的函数必须存在无法达到的终点
function error(message: string): never {
    throw new Error(message);
}
// 推断的返回值类型为never
function fail() {
    return error("Something failed");
}

void void也有它的应用场景

  • 表示的是没有任何类型,当一个函数没有返回值时,通常typescript会自动认为它的返回值时void
  • 在代码中声明void类型或者返回值标记为void可以提高代码的可读性,让人明确该方法是不会有返回值,写测试时也可以避免去关注返回值。
  public remove(): void {
    if (this.container) {
      this.mapContainer.removeChild(this.container);
    }
    this.container = null;
  }

小结

  • never实质表示的是那些永远不存在值的类型,也可以表示函数表达式或箭头函数表达式的返回值。
  • 我们可以定义函数或变量为void类型,变量仍然可以被赋值undefinednull,但是never是只能被返回值为never的函数赋值。

2.6 枚举类型

ts中用enum关键字来定义枚举类型,似乎在很多强类型语言中都有枚举的存在,然而Javascrip没有,枚举可以帮助我们更好地用有意义的命名去取代那些代码中经常出现的magic number或有特定意义的值。这里有个在我们的业务里用到的枚举类型:

export enum GEO_LEVEL {
  NATION = 1,
  PROVINCE = 2,
  CITY = 3,
  DISTRICT = 4,
  BUILDING = 6,
  FLOOR = 7,
  ROOM = 8,
  POINT = 9,
}

因为值都是number,一般也被称为数值型枚举。

基于数值的枚举 ts的枚举都是基于数值类型的,数值可以被赋值到枚举比如:

enum Color {
    Red,
    Green,
    Blue
}
var col = Color.Red;
col = 0; // 与Color.Red的效果一样

ts内部实现 我们看看上面的枚举值为数值类型的枚举类型会怎样被转为JavaScript:

// 转译后的Javascript
define(["require", "exports"], function (require, exports) {
    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    var GEO_LEVEL;
    (function (GEO_LEVEL) {
        GEO_LEVEL[GEO_LEVEL["NATION"] = 1] = "NATION";
        GEO_LEVEL[GEO_LEVEL["PROVINCE"] = 2] = "PROVINCE";
        GEO_LEVEL[GEO_LEVEL["CITY"] = 3] = "CITY";
        GEO_LEVEL[GEO_LEVEL["DISTRICT"] = 4] = "DISTRICT";
        GEO_LEVEL[GEO_LEVEL["BUILDING"] = 6] = "BUILDING";
        GEO_LEVEL[GEO_LEVEL["FLOOR"] = 7] = "FLOOR";
        GEO_LEVEL[GEO_LEVEL["ROOM"] = 8] = "ROOM";
        GEO_LEVEL[GEO_LEVEL["POINT"] = 9] = "POINT";
    })(GEO_LEVEL = exports.GEO_LEVEL || (exports.GEO_LEVEL = {}));
});

非常有趣,我们先不去想为什么要这么转译,换个角度思考,其实上面的代码说明了这样一个事情:

console.log(GEO_LEVEL[1]); // 'NATION'
console.log(GEO_LEVEL['NATION']) // 1
// GEO_LEVEL[GEO_LEVEL.NATION] === GEO_LEVEL[1]

所以其实我们可以通过这个枚举变量GEO_LEVEL去将下标表示的枚举转为key表示的枚举,key表示的枚举也可以转为用下标表示。

3. Reference

design pattern in typescript

typescript deep dive

tslint rules

typescript中文文档

typescript 高级类型

you might not need typescript

advanced typescript classes and types

作者:XXXSpade
链接:https://juejin.cn/post/6844903985959190541
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):