typescript 深化学习,类型运算的原始动力

957 阅读16分钟

wo.png

typescript 类型推导是一个非常有趣的过程,你就像是在做一个推理游戏。在这个游戏里,主角就是你,是一个天才侦探,擅长推理;在分析掌握的类型信息后,通过对类型操作运算,得出最终的类型。

假设此时,你已经对typescript 的基础,比如:基本类型声明,泛型,类型别名,有一定的自己理解。如何没有,你可看一下typescript 打好基础,走起..。类型推导是一个演变的过程,类型运算让这个过程变得非常的灵活多变。在开始之前,请在记住一点: 对类型进行运算,为类型表达提供无尽可能

类型操作基础

在typescript 文档有提到2种类型:联合类型和交叉类型,也就是 Unions and Intersection Types。通过2个符号 |& 来实现。在类型推导中有一个非常重要的术语叫满足于, 也就是类型推导成立,满足对类型约束的最低要求。在发生类型错误时,经常可以看到编译器提示:'类型X' is not assignable to type '类型Y',比如:'number' is not assignable to type 'string'。数字类型不可以赋值给字符串类型。

满足于也好, 可赋值给也好,他们都是对类型推导是否成立的一个结果,相当于True or False,没有什么实质上的区别,只是在类型推导过程,有时候使用其中一个比另个更加的自然,符合一些语言表达上的习惯。

联合/交叉 类型

联合/交叉 类型,实际上是比较简单的概念,我们来对比一下:

  • 联合类型(unions type):满足其中一个对类型约束的条件即可
  • 交叉类型:必须同是满足所有对类型约束的条件才可以
// 联合类型

type Odd = 1 | 3 | 5;

// 1 是 (1,3,5) 其中的一个,满足类型约束条件之一
const a: Odd = 1;

// 报类型错误:Type '2' is not assignable to type 'Odd'
// 因为2不满足任何的一个类型约束条件
const b: Odd = 2;

// 交叉类型

// 条件A:
interface A {
  str: string;
}

// 条件B
interface B {
  num: number;
}

// 同时满足条件AB
type AB = A & B;

/**
 * 同时满足2个类型的约束条件
 * 条件一:str 是字符串
 * 条件二:num 是数字
 */
const Both: AB = {
  str: "hello world",
  num: 0,
};

|, & 对类型操作,实际上是对约束条件的关系处理。在业务开发中,可能用得更多的是对泛型类型参数约束,多个类型进行组合:

// 状态
type MediaType = "Text" | "Image" | "Video" | "Audio";

// 媒体的公有属性
// 类型参数 T 继承于 MediaType, 默认值 Video
// 也就是 T 要满足于对 MediaTyped 类型约束
interface Media<T extends MediaType = "Video"> {
  // 标识符
  id: string;
  // 名称
  name: string;
  // 媒体类型
  type: T;
}

type VideoMedia = Media & {
  // 播放时长
  duration: number;
};

const video: VideoMedia = {
  id: 0,
  name: "我是一个视频",
  type: "Video",
  duration: 120,
};

联合/交叉 类型, 不仅仅可以从定义某一个类型上理解,也可以从运算的角度去看,理解为一个类型运算符,像加,减,乘,除一样,是一个推导约束操作,会更加生动有趣。

运算符

typeScript 的类型系统非常强大,因为它允许根据其他类型来表达类型。通过类型运算符各种组合,我们可以以一种简洁,可维护的方式表达复杂的操作和值。

keyof 运算符

keyof 作用于一个对象类型(mapped types),并以它的键生成字符串或者数字组成的联合类型。

type Point = { x: number; y: number };

// 提取键 "x" | "y"
type Props = keyof Point;

type Mapish = { [k: string]: boolean };

/**
 * 提取到的是 string | number;
 * 因为在javascripte 中 obj[0] 会转化为 obj["0"]
 */
type Keys = keyof Mapish;

上面的索引签名涉及到类型兼容问题,有必要说明一下具体的原因。TypeScript支持两种索引签名:字符串和数字。 可以同时使用两种类型的索引,但是数字索引的返回值必须是字符串索引返回值类型的子类型。 这是因为当使用 number来索引时,JavaScript会将它转换成string然后再去索引对象。 也就是说用 100(一个number)去索引等同于使用"100"(一个string)去索引,因此两者需要保持一致。


class Animal {
    name: string;
}
class Dog extends Animal {
    breed: string;
}

// 错误:使用数值型的字符串索引,有时会得到完全不同的Animal!
interface NotOkay {
    [x: number]: Animal;
    [x: string]: Dog;
}
typeof 运算符

TypeScript 添加了一个typeof运算符,您可以在类型上下文中使用它来引用变量或属性的类型。

  • typeof 作用的对象是一个javascript 值,不可以是类型
  • 对一个javascript 值,使用 typeof 会推导出值对应的类型。
let a = { id: 0, name: "hello world" };

// 推断出 变量 a 的类型是 { id: number;name: string;}
let b: typeof a = {
  id: 0,
  name: "",
};

interface A {
  id: string;
}

// 报类型错误,typeof 不可以作用于一个 类型上
type C = typeof A;

索引访问

我们可以通过索引的方式,访问一个类型的属性

type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"];

const MyArray = [
  { name: "Alice", age: 15 },
  { name: "Bob", age: 23 },
  { name: "Eve", age: 38 },
];

// 提取到 { name: string; age: number; }
type ListItem = typeof MyArray[number];
type AgeType = typeof MyArray[number]["age"];


const key = "name";

// 报类型错误 'key' refers to a value, but is being used as a type here
// key 必须是一个类型
type NameType = Person[key];

// 推导成功
type A = Person[typeof key];

object 对象类型

  • in 运算符:[Property in keyof Type] 把键限制在一个集合中,就是keyof 提取出来的联合类型
  • 移除/添加修饰符:通过 - 或者 +, 表示移除/添加修饰符(主要是指:readonly, ?)
  • 重命名键名:对一个键名重命名, 4.1 以后的特性,要安装 JavaScript and TypeScript Nightly
  • 对键类型运算:以键作为类型参数,进行类型运算
/// in 运算符
// [Property in keyof Type] 索引签名表示每一个键都是来自类型参数 Type
type OptionsFlags<Type> = {
  [Property in keyof Type]: boolean;
};

type FeatureFlags = {
  darkMode: () => void;
  newUserProfile: () => void;
};

/**
 * 运算结果: { darkMode: boolean; newUserProfile: boolean; };
 */
type FeatureOptions = OptionsFlags<FeatureFlags>;

// 同时移除 readonly, 可选 - 修饰符
type RemoveModifiers<Type> = {
  -readonly [Property in keyof Type]-?: Type[Property];
};

type MaybeUser = {
  readonly id?: string;
  name?: string;
  age?: number;
};

// 运算结果: { id: string;  name: string; age: number; }
type MustUser = RemoveModifiers<MaybeUser>;

type RenameMapKey<Type> = {
  [Property in keyof Type as `re_${string & Property}`]: Type[Property];
};

// 运算结果:{ readonly re_id?: string; re_name?: string; re_age?: number; }
// 很明显:每一个原来的键都加上 re 这个前缀
type RenameUser = RenameMapKey<MaybeUser>;

type KeyMoreOperator<Type> = {
  // keyof 提取到键, 通过 Type[Property] 索引访问,你可进行任何的类型运算
  // 这里只是抛砖引玉
  [Property in keyof Type]: Type[Property] extends { [p: string]: number[] }
    ? string
    : "others";
};

模板字符类型

模板文字类型建立在字符串文字类型之上,并且能够通过联合扩展为多个字符串。

在插值使用文字类型
type Str = "a" | "b" | "c";

type Num = 0 | 1 | 2;

//
type StrNum = `${Str | Num}`;

type Str_Num = Str | Num;

// 类型报错,因为类型 StrNum 是一个模板字符串类型,
// 在类型运算时 0 | 1 | 2 被内置函数转化为 "0" | "1" | "2"
let a: StrNum = 0;

// 可以的,等式成立
let b: Str_Num = 0;
内在字符串操作类型

为了帮助进行字符串操作,TypeScript 包含一组可用于字符串操作的类型。这些类型内置于编译器中以提高性能,并且无法在.d.tsTypeScript 包含的文件中找到。

type Greeting = "Hello, world"
type ShoutyGreeting = Uppercase<Greeting>
           
type ShoutyGreeting = "HELLO, WORLD"

从 TypeScript 4.1 开始,这些内置函数是直接使用 JavaScript 字符串运行时函数进行操作,并且使用者是感觉不到的。

function applyStringMapping(symbol: Symbol, str: string) {
    switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {
        case IntrinsicTypeKind.Uppercase: return str.toUpperCase();
        case IntrinsicTypeKind.Lowercase: return str.toLowerCase();
        case IntrinsicTypeKind.Capitalize: return str.charAt(0).toUpperCase() + str.slice(1);
        case IntrinsicTypeKind.Uncapitalize: return str.charAt(0).toLowerCase() + str.slice(1);
    }
    return str;
}

请注意版本要求4.1以上,推荐使用vs code 编辑器,并安装 JavaScript and TypeScript Nightly 插件

条件类型

条件类型就是一个三元表达式:SomeType extends OtherType ? TrueType : FalseType, 为类型推导提供一个逻辑分支处理能力,是类型表达提供if ... else 能力。 关键字 extends 继承的意思,可以理解成 满足于 什么条件,可以是条件子类类,是一种类型约束表述。

条件类型是在typescript 2.8 版本中有详细的说明,可以看一下文档,英语还不错的,建议看官方文档,不容易遗漏关键细节。


// 返回基本类型
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"

// 车
interface Car {
  wheel: 4;
}

// 房子
interface House {
  bed: true;
}

// 输入的类型参数 T 必须满足是车的有求
// 如果同时具有车,房的属性,就是房车,否则就是普通的车
type CarType<T extends Car> = T extends Car & House ? "motorhome" : "car";

分布条件类型

当输入的类型是联合类型或者交叉类型,条件类型是如何处理的呢?

  • 联合类型:分布式条件会自动转化分布式的联合类型,一个一个约束条件的输入到类型参数中运算,再把得到结果类型用 | 合并
  • 交叉类型:它就是一个类型,不用单独处理了,可以直接做类型操作
// 返回类型:"string" | "function"
// 第一次: string 输入 TypeName(string) 得到 string
// 第二次:() => void 输入 TypeName(() => void) 得到 function
// 把结果:string, function 用 `|` 链接得到联合类型 "string" | "function"
type T1 = TypeName<string | (() => void)>;

// 返回类型:function,
// 输入的类型参数 () => number 满足 T extends Function 的约束
type T2 = TypeName<string[] & (() => number)>;

类型变量的推断

在条件类型中,通过 infer 关键字声明一个类型变量要延迟推断。可以从2个方面去理解:

  1. 类型变量:它是一个类型结构中的占位符,表示在指定位置上的各种类型泛指
  2. 推断:是一个延迟过程,待到类型参数的输入时,才可以推断出类型变量的类型
/**
 * 定义一个解包类型 Unpacked
 * U 是一类型变量,分别在不同的位置作为类型占位符去推断
 */
type Unpacked<T> = T extends (infer U)[]
  ? U
  : T extends (...args: any[]) => infer U
  ? U
  : T extends Promise<infer U>
  ? U
  : T;

/**
 * Promise<string>[] 满足于 T extends (infer U)[]
 * 类型变量 U 是 (infer U)[] 数组中元素的类型占位符
 * 则可以推断出 U 指的就是 Promise<string>
 */
type TP = Unpacked<Promise<string>[]>; // Promise<string>

类型变量的位置

根据类型变量的位置,可以分为协变和逆变。维基百科是这样定义的:“协变与逆变(Covariance and contravariance)是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语”。

在一门程式设计语言的型别系统中,一个型别规则或者型别构造器是:

  • 协变(covariant),如果它保持了子型别序关系≦。该序关系是:子型别≦基型别。
  • 逆变(contravariant),如果它逆转了子型别序关系。
  • 不变(invariant),如果上述两种均不适用。
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

// 协变
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

/**
 * 逆变
 * 作为函数参数类型,把类型范围限制更加的紧,可以
 */
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

/**
 * 当推断具有多个调用签名(例如函数重载类型)的类型时,用最后的签名
 * 无法根据参数类型列表来解析重载
 */
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

协变和逆变都是术语,前者指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型,后者指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型。

Utility Types

现在你已经拥有对类型的操作运算的能力,他是类型推导的原始动力。在做类型转换,表达一些复杂类型时,经常要用到一些其它类型去运算。typescript 为我们提供了一些经内置的实用类型,你可以直接使用。

内置的类型,可以 command + 点击 查看定义,为了更加直观,方便理解,把直接定义复制出来了

Partial<Type>

把Type所有属性都设置为可选的类型


// 定义
type Partial<T> = {
    // 把每个属性设置为可选的类型
    [P in keyof T]?: T[P];
};

type Item = {
  name: string;
  status: number;
};

// 修改数据项时,可以只提供其中几个属性
function setItem(input: Partial<Item>) {
  // do something
}

setItem({ name: "hello world" });

Required<Type>

Required:把Type所有属性都设置为必须提供的类型


// 定义
type Required<T> = {
    [P in keyof T]-?: T[P];
};


type Item = {
  id: number;
  status?: number;
};

// 更新状态时,必须提供status 的值
function setStatus(input: Required<Item>) {
  // do something
}

// 没有提供status 字段,报类型错误
setStatus({ id: 0 });

Readonly<Type>

Readonly:把Type所有属性都设置为只读的类型

// 定义
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

type Item = {
  id: number;
  name: string;
  status?: number;
};

// 运算结果: { readonly id: number; readonly name: string; readonly status?: number; }
type LockItem = Readonly<Item>;

Record<Keys,Type>

构造一个对象类型,其属性键为Keys类型,属性值为Type类型。可用于将一种类型的属性映射到另一种类型

// 定义
type Record<K extends keyof any, T> = {
    [P in K]: T;
};

type Item = {
  id: number;
  name: string;
  status?: number;
};

type Keys = "a" | "b" | "c";

// 运算结果: { a: Item; b: Item; c: Item; }
type ItemMap = Record<Keys, Item>;

// 本质上是一种类型的属性映射到另一种类型,键值对的映射
type ItemList = Record<number, Item>;
const list: ItemList = [{ id: 1, name: "hello world" }];

Pick<Type, Keys>

选取一组属性Keys(字符串文字或字符串文字的并集)在类型Type属性集合中(也就是keyof Type)的键来构造一个类型。

  1. Keys 必须在 keyof Type 中,否则踢除
  2. 满足上面条件的Keys, 通过索引访问得到原来的类型
// 定义
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};
type Item = {
  a: number;
  b: string;
  c?: number;
};

type Keys = "a" | "b" | "d";

// 运算结果:{ a: number; b: string; }
// 因为 a, b 都是 Item 属性,d 不是
type SubItem = Pick<Item, Keys>;

Omit<Type, Keys>

从类型Type属性集合中(也就是keyof Type)删除在里面的Keys(字符串文字或字符串文字的并集),剩余的用来构造一个类型。

  1. 在 keyof Type 中踢除Keys,剩余的作为新类型的键
  2. 满足上面条件的剩余的键, 通过索引访问得到原来的类型
// 定义
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

type Item = {
  a: number;
  b: string;
  c?: number;
};

type Keys = "a" | "b";

// 运算结果:{  c?: number; }
type SubItem = Omit<Item, Keys>;

Pick,Omit 运算中都使用到keyof, 索引访问,他们是操作键值对类型的利器

Exclude<Type, ExcludedUnion>

从类型Type中,排除指定的ExcludedUnion产生一个新的类型。

  • Type 是一个联合类型,否则排除就无从谈起
  • ExcludedUnion 从词意上来看,也是要求是一个联合类型,当然这不是必须的,是一个类型就是可以的,不过是一个只有一个类型的联合类型
  • 过滤操作使用的关键字是 extends 满足于

本质上讲,就是一过滤操作,过滤的条件就是 ExcludedUnion 中的类型是否可以赋值给Type,,就排除。

// 定义
type Extract<T, U> = T extends U ? T : never;

type Keys = "a" | "b" | "c" | "d";

// 运算结果:"c" | "d"
type ResKeys = Exclude<Keys, "a" | "b">;

Extract<Type, Union>

从类型Type中,提取可以满足珠指定的Union类型,以生成一个新的类型。

  • Type 是一个联合类型,否则提取也是无从谈起
  • Union 从词意上来看,也是要求是一个联合类型,当然这不是必须的,是一个类型就是可以的,不过是一个只有一个类型的联合类型
  • 使用的关键字是 extends 满足于

本质上讲,也就是一个过滤操作,过滤的条件就是提取出来的类型要满足于Union, 就提取出来作为新类的组成部分。

// 定义
type Extract<T, U> = T extends U ? T : never;

type Keys = "a" | "b" | "c" | "d";

// 运算结果:"a" | "b"
type ComKeys = Extract<Keys, "a" | "b">;

NonNullable<Type>

从Type 类型中,排除null和undefined,构造一个新的类型

// 定义
type NonNullable<T> = T extends null | undefined ? never : T;

// 运算结果:string[]
type T1 = NonNullable<string[] | null | undefined>;

typescript 也围绕函数的类型运算提供了参数类型、返回值类型、this 的类型、构造函数类型的类型操作工具。

Parameters<Type>

根据函数类型的参数中使用的类型构造元组类型。因为javacript 的函数参数内部是一个数组,返回一个元祖类型,也是很好理解的。

// 定义
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;


const fun1 = (a: { name: string }, price: number) => {
  return a.name + print;
};

// 运算结果: [a: { name: string; }, price: number]
type A = Parameters<typeof fun1>;

// 元祖类型的第一个类型 { name: string; }
type B = A[0];

// 报类型错误,Parameters 返回的是一个元祖,只支持数字索引访问
// a: { name: string; } 是对类型的描述,不要理解为object 对象
type C = A["a"];

ReturnType<Type>

由函数的返回值类型构造一个类型,就是提取函数的返回值的类型


// 定义
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;


const f1 = (): string[] => {
  return [];
};

type RType = ReturnType<typeof f1>;

ConstructorParameters<Type>

返回构造函数的参数类型元祖

// 定义
type ConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;

interface Duck {
  new (a: string, b: number): { name: string };
}

type ConTor = ConstructorParameters<Duck>;

// 报类型错误,因为 F1 没有明确声明 new 签名
type ProtoType = ConstructorParameters<typeof F1>;

InstanceType

获取构造函数的返回类型,这样太官方了,换一个说法:返回实例的类型。

// 定义
type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;

class Cat {}
function Dog() {}

type CatType = InstanceType<typeof Cat>;

let cat: CatType = new Cat();

// 类型错误:没有提供 new (...args: any): any 签名
// 为什么会这样? Dog 构造函数,可以被当作普通参数调用
// class Cat 不可以,只允许用new 调用
type DogType = InstanceType<typeof Dog>;

// 类型错误:属性 prototype 在 Cat 实例中没有
// new Cat() 返回的是实例
let cat1: typeof Cat = new Cat();

// 类型推导成功
// 可以看得出 InstanceType 就是一个 typeof class["prototype"] 一个语法糖
let cat2: typeof Cat["prototype"] = new Cat();

ThisParameterType<Type>

返回函数this 的类型,因为 this 如果有,必须放在参数的第一个,通过infer 很容易提取到。

type ThisParameterType<T> = T extends (this: infer U, ...args: any[]) => any ? U : unknown;

class Duck {
  name = "hello world";
}

function fun(this: typeof Duck) {
  console.log(this.name);
}

const duck = new Duck();
// 指定调用时 this 指向 duck
fun.call(duck);

OmitThisParameter<Type>

忽略函数的this参数类型,如何理解呢?

  • 情况一:没有声明this 参数类型,直接返回 Type
  • 情况二:如果type中有声明 this 参数类型, 则忽略 this 类型,返回剩余参数类型的作为新的函数类型,函数的返回值类型不变。
type OmitThisParameter<T> = unknown extends ThisParameterType<T> ? T : T extends (...args: infer A) => infer R ? (...args: A) => R : T;

function a(num: number): string {
  return `${num}`;
}

function b(this: Window, str: string) {}

// 运算结果: (num: number) => string
type A = OmitThisParameter<typeof a>;

// 运算结果: (str: string) => void
type B = OmitThisParameter<typeof b>;

// 绑定 this 并返回新的函数
let bFun: B = b.bind(window);

ThisType<Type>

ThisType 这个类型工具不返回转换的类型,它作为 this 上下文类型的标记符而服务。只有打开 -- noImplicitThis 选项,才可以使用。


/**
 *  定义就是一个空类型
 * 
 */
interface ThisType<T> { }


type ObjectDescriptor<D, M> = {
  data?: D;
  // 标记在methodsk中 this 的类型是 D & M
  methods?: M & ThisType<D & M>; 
};

function makeObject<D, M>(desc: ObjectDescriptor<D, M>): D & M {
  let data: object = desc.data || {};
  let methods: object = desc.methods || {};
  return { ...data, ...methods } as D & M;
}

let obj = makeObject({
  data: { x: 0, y: 0 },
  methods: {
    moveBy(dx: number, dy: number) {
      this.x += dx; // Strongly typed this
      this.y += dy; // Strongly typed this
    },
  },
});

obj.x = 10;
obj.y = 20;
obj.moveBy(5, 5);

Intrinsic String Manipulation Types

TypeScript 包含一组可用于类型系统中的字符串操作的类型。您可以在模板文字类型文档中找到这些内容。 可以看上面模板文本类型

结语

类型运算是typescipt 表达能力的核心,如:keyof, extends, in, typeof, 条件类型,infer 类型变量,this 类型这些,就像数学中的加,减,乘,除,一定要了然于心,才可以运用自如。

当学习一个编程技术,贴近业务,试着写一写,会有不一样的发现,有兴趣的可以看一下。

typecript 是一个完整的体系,进一步学习的还有声明文件,项目配置。文章中如有错漏,欢迎指正,谢谢。