玩转typescript 类型

933 阅读14分钟

欢迎各位多加指正

1. 类型是什么

简单来说,类型就是为编程语言提供不同内容的抽象:

  • 不同类型变量占据内存大小
  • 不同类型变量可做的操作不同 保证某种类型只允许做该类型的操作叫做类型安全。比如number类型可以进行加减乘除的操作,而boolean类型不可以做这些操作,但是js是一种弱类型语言,即使对boolean类型进行了加减乘除,也不会报错,同时为了保证数据的正确运行,会进行强制类型转换,这就可能导致程序莫名的bug出现。
const num = 1;
const b = true;
const res = num + b; // 2

那么我们怎么避免这样的不可控错误,答案就是类型检查。

保证类型安全方式叫做类型检查,根据类型检查的时间可以分为两种:

  • 动态类型:运行时进行检查。
  • 静态类型:编译时进行检查。

两种类型各有优劣,动态类型语言比较灵活,但是有类型不安地隐患;静态类型增加了代码编写的难度,但很多bug可以在编译阶段检查出来,从而可以消除不安全隐患。

可以做隐式类型转换的语言,叫做弱类型,不允许隐式类型转换的语言,叫做强类型。

动态类型只适合简单场景,对于大型项目(尤其是多人合作的大型项目)却不太合适,因为动态类型没法做约束,代码中会隐藏大量的隐患。而静态类型可以保证类型安全,很好的保证代码的健壮性,减少bug。

1.1 类型系统分类

静态语言都有自己的类型系统,从简单到复杂可以分为3中类型:

  • 简单类型系统

简单类型系统可以支持定义number,boolean,string,以及class。同时编译器也保证编译阶段的类型检测,保证类型安全。那么这样的类型系统有什么缺点呢?就是太死板,比如我们定义一个支持float和int的函数,需要用多态实现:

int add(int a, int b) {
    return a + b;
}
​
int add(float a, float b) {
    return a + b;
}

那么这样的缺点如何解决呢,我们很容想到,如果类型不是在定义的时候确定,而是在调用的时候确定就好了,这就是第二种类型系统——支持泛型的类型系统。

  • 支持泛型的类型系统

    支持泛型的类型系统:泛型的英文是 Generic Type,通用的类型,它可以代表任何一种类型,也叫做类型参数。这样的类型系统大大增加了语言的灵活性,比如上面的例子可以如下改写:

    T add<T>(T a, T b){
        return a + b;
    }
    

    泛型系统的类型不是在定义的时候确定的,而是在调用时候确定,同时保证类型可以被记一下来,java就是这样的类型系统。支持泛型的类型系统极大的增强了语言的灵活性。但是对于JavaScript来说,还是远远不够的,因为JavaScript太过灵活了。

    为了满足JavaScript灵活性,就要有第三种类型系统——支持编程的类型系统。

  • 支持编程的类型系统(图灵完备)

对传入的参数进行各种逻辑运算,最终产生新的类型,这就是可编程的类型系统。

JavaScript真的需要这么复杂的类型系统吗?答案是确定。因为JavaScript实在是太灵活了,对于Java来说,所有的对象都是new出来的,而JavaScript对象可以是new出来,也可以是字面量,这就需要复杂的类型系统来保证类型的完备,比如下面例子,在Java中是绝对不能实现:

function<T>(obj: T, key: extends keyof T):T[key] {
    return T[key];
}

TypeScript 的类型系统是图灵完备的,也就是能描述各种可计算逻辑。简单点来理解就是循环、条件等各种 JS 里面有的语法它都有,JS 能写的逻辑它都能写。

那么下面就让我们开始TypeScript类型之旅吧。

2. 基础类型

这一部分的类型也是 JavaScript 中的基本类型,但也增加了一些非常有用的类型如tupleenum,虽然他们的本质是Arrayobject

Boolean

boolean是很简单falsetrue 的集合

let isCompleted: boolean = false;

Number

number是所有数字的集合,各个进制数字的写法也和 JavaScript 保持一致。

let decimal: number = 6;
let hex: number = 0xf00d;
let binary: number = 0b1010;
let octal: number = 0o744;

String

在 TypeScript 中同样支持双引号和单引号的字符串。

let color: string = "blue";
color = 'red';

Array

TypeScript 中数组的写法有两种。

// Type[]
let list: number[] = [1, 2, 3];

// Array<Type>
let list: Array<number> = [1, 2, 3];

而因为有可能用到 JSX 语法,第二种写法会和 JSX 语法冲突,为了保持一致性,所以推荐使用第一种。

数组类型也是一些变量的集合,例如:

number[]就是[number][number, number][number, number, number]...的集合,而其中的number则如上所说是所有数字的集合。

Tuple

元组类型是几种类型的数组形式的固定组合,如下:

// Declare a tuple type
let x: [string, number];
// Initialize it
x = ["hello", 10]; // OK
// Initialize it incorrectly
x = [10, "hello"]; // Error

元组类型在我看来是数组类型的子类型,如上number[]就是一些元组的集合。

[string, number]则是string | number[]的子类型。即string | number[]number[]string[][string, number ...]...,其中就包括一个[string, number]

Enum

enum这个操作符比较特殊,它的定位类似于varletconst,用于声明变量,但它只支持特定结构的变量声明,而这个结构就是一个对象。它的用法如下:

enum Color {Red, Green, Blue}
let c: Color = Color.Green;

它的本质就是构建了一个对象,对象中属性的值默认从0开始,依次加1。 当然你也可以设置为其他的值。

enum Color {Red = 1, Green = 2, Blue = 4}
let c: Color = Color.Green;

Object

object是对象类型,即它是所有对象的集合。

2.1 特殊类型

这里会介绍一下在 TypeScript 类型系统中的空值、bottom type、top type等。

Null & Undefined

nullundefined都是空值,但因为在 JavaScript 中typeof操作符的行为,未定义的变量和定义了但未赋值的变量都是undefined,所以推荐定义了变量之后不会即刻赋值时,设置空值为null,这样可以区分开typeof的行为,当然这样可能会引入另一个问题,即typeof nullobject但依旧推荐这样做,可以在判断空值时直接使用foo != null

Unknown

unknown是 TypeScript 中的 top type,即任何类型都是它的子类型,它是 TypeScript 中所有可选值的集合。

Never

never是 TypeScript 中的 bottom type,即它是任何类型的子类型,但在 TypeScript 中它有着其他的作用,比如,当尝试给一个never类型的变量赋值时,中断当前程序运行,并抛出异常。

Any

any是 TypeScript 中非常特殊的类型,它既是 top type,又是 bottom type,即任何类型都是它的子类型,它又是任何类型的子类型。是不是很矛盾?但它的价值就在这里,TypeScript 目前还无法完美支持 JavaScript 的所有能力,any就相当于一个缓冲,就是当你要做的事情 TypeScript 当前的类型系统还不支持的时候,就用any告诉编译器,这个你还不懂,但它是对的,然后编译器会非常相信你,当遇到any的时候,不做任何的类型检查。

所以在使用any之前,你要用尽浑身解数,尝试用 TypeScript 当前支持的能力来完成你所要做的工作,但当你发现 TypeScript 无法做到的时候,你就可以使用any了。

3 高级类型

3.1 操作符

在进入高级类型之前,我们先看几个操作符:

  • typeof

typeof在typescript中还可以用来返回一个变量的声明类型,如果不存在,则获取该类型的推论类型。

let n: number = 1
type TN = typeof n
​
/** 等同于
 * TN == number
 */
​
let s:string = 'hello world'
type TS = typeof s
​
/** 等同于
 * TS == string
 */
​
let a: Array<number> = []
type TA = typeof a
​
/** 等同于
 * TA == number[]
 */
​
let sy: Symbol = Symbol()
type TSY = typeof sy
​
/** 等同于
 * TSY == Symbol
 */
​
​
let obj = {name:'zhangsan', age:28, male:true, run:()=>'run'}
type TO = typeof obj
​
/** 等同于
 * type TO = {
 *  name: string;
 *  age: number;
 *  male: boolean;
 *  run: () => string;
 * }
 */
  • keyof

TypeScript允许我们遍历某种类型的属性,并通过keyof操作符提取其属性的名称,类似Object.keys方法。keyof操作符是在TypeScript 2.1版本引入的,可以用于获取某种类型的所有键,其返回类型是联合类型

interface Person {
    name: string;
    age: number;
    location: string;
}
​
type K1 = keyof Person; // "name" | "age" | "location"
type K2 = keyof Person[];  // "length" | "push" | "pop" | "concat" | ...
type K3 = keyof { [x: string]: Person };  // string

keyof可以结合typeof一起使用,用于获取变量声明类型的key值的联合类型。这也给了我们一种获取联合类型的方式

let colors = {
    red: 'Red',
    green:'Green',
    blue:'Blue'
}
​
type TColors = keyof typeof colors // 'red' | 'green' | 'blue'
  • in

用于遍历类型的属性key值,一般和keyof联合使用:

interface Person {
    name: string;
    age: number;
}
​
type Partial<T> {
    [key in keyof T]?: T[key]
}
​
type Per = Partial<Person>;
​
/**
得到如下类型
    type per = {
        name?: string;
        age?: number;
    }
*/
  • extends

这里的extends只是在类型系统的中的使用方法,不包括class的继承。

extends在Typescript用法较多,我们一一剖析:

  • 用于限制类型范围:
function get<T extends Object, Key extends keyof T>(obj: T, key: Key): T[Key] {
    return obj[key];
}
  • 用于条件类型

typescript 2.8引入了条件类型表达式,类似于三元运算符,在这种意义下,extends提供了判断语句:

type isNull<T> = T extends null | undefined ? true : false;
​
type notNull = isNull<number>; // ==> false
type isNull = isNull<null>; // ==> true
  • infer

infer可以用来声明一个待推断的类型变量,简单来说,相当于JavaScript中的const

type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : any;
type T0 = ReturnType<() => string>  // string
type T1 = ReturnType<(s: string) => void> // void
type T2 = ReturnType<<T>() => T>  // unkonwn

下面让我们根据这些操作符来玩转Typescript的高级类型。

3.2 模式匹配

模式匹配是我们使用Typescript最有用的特性之一,我们要实现我们要实现数组的增删改查,数据的提取,字符串的操作等,都需要用到这个特性。而Typescript 实现模糊匹配的操作符主要是infer,用于声明局部变量。

infer操作符只能和extends配合使用

关于条件类型中 infer 的官方文档:Inferring Within Conditional Types

那么这个属性怎么用呢,我们从针对字符串,数组,函数等操作来玩这个操作符。

3.2.1 数组

  • First

首先我们从数组中抽取第一个元素

type Arr = [1, 2, 3];
​
type FirstArr<T extends unknown[]> = T extends [infer First, ...unknown[]] ? First : never;

对数组进行模糊匹配,我们要抽出第一个元素类型,放到通过infer声明的局部变量中,后面的可以是任意类型,放到unknown[]中,然后把局部变量First返回。

  • End

同理我们可以抽取最后一个元素

type Arr = [1, 2, 3];
​
type EndArr<T extends unknown[]> = T extends [...unknown[], infer End] ? End : never;
  • Rest

既然能取首位,我们就能取剩余类型

type Arr = [1, 2, 3];
​
type RestArr<T extends unknown[]> = T extends [infer First, ...infer Rest, infer End] ? Rest : never;
​
type End = RestArr<Arr>; // [2]

试一试

3.2.2 字符串

字符串类型也同样可以做模式匹配,匹配一个模式字符串,把需要提取的部分放到 infer 声明的局部变量里。

  • startWith

我们判断一个字符是否以某个字符开头:

type StartWith<T extends string, Prefix extends string> = 
    T extends `${Prefix}${string}` ? true : false;
​
type IsStart = StartWith<'hello word', 'hello'>; // true
  • trim

字符串可以做模糊匹配,当然也可以做trim

type Trim<T extends string> = 
    T extends `${' '|'\n'|'\t'}${infer Rest}${' '|'\n'|'\t'}` ? Rest : never;
​
type NoTrim = Trim<' hello word '>; // hello word
  • Replace

我们可以对字符进行trim操作,那我们就肯定可以做replace操作

type Replace<T extends string, From extends srting, To extends> = 
    T extends `${infer Prefix}${From}${infer Suffix}` 
        ? `${infer Prefix}${To}${infer Suffix}` 
        : never

3.2.2 函数

当然我们也可以对函数进行模式匹配,比如提取参数,返回值类型

  • Parameters

获取函数的参数类型:

type Parameters<T extends (...arg: any[]) => any> = 
    T extends (...args: infer Args) => any ? Args : never;
​
type TestParameter = Parameters<(name: string, age: number) => void>; //[name: string, age: number]
  • ReturnType

能获取参数,就能获取返回值

type ReturnType<T extends (...arg: any[]) => any> = 
    T extends (...args: any[]) => infer Return ? Return : never;
​
type TestReturnType =  ReturnType1<(name: string, age: number) => number>; // number

3.3 重新构造

typescript 支持三种可以声明任意类型的变量,type,infer,类型参数,但三种方式都不能对原始类型进行修改,如果我们想修改原始类型,就需要重新构造,产生新的类型。

3.3.1 数组构造

针对数组我们可以做到增删改。

  • Push
type ArrayPush<T extends unknown[], Ele extends unknown> = [...T, Ele]
​
type TestPush<[1,2], 3>; // [1, 2, 3]
  • Unshift

可以在后面添加,当然也可以在前面添加

type ArrayUnshift<T extends unknown[], Ele extends unknown> = [Ele, ...T]
​
type TestUnshift<[1,2], 3>; // [3, 1, 2]

3.3.2 字符型

我们可以对数组进行增删改,那么我们可以对字符串有哪些操作呢?

  • Uppercase

首先,我们可以对字符串进行首字母大写的操作

type GetUppercase<Str extends string> = 
    Str extends `${infer Prefix}${infer Rest}`
    ? `${Uppercase<Prefix>}${Rest}` : never;
  • CamelCase

既然我们可以转写首字母,当然我们也可以将下划线类型,转为驼峰

type CamelCase<Str extends string> = 
    Str extends `${infer Left}_${infer Right}` ?
    `${Left}${GetUppercase<Right>}` : never;
​
type TestCamelCase = CamelCase<'test_came'>; // testCame

上面的例子只是转换了一个下划线,如果有多个下划线我们怎么修改呢,答案是递归调用,后面我们在玩递归时会再次做这样的转换。

  • DeleteStr

既然能做转换,我们肯定就能做删除

type DeleteStr<Str extends string, Target extends string> = 
    Str extends `${infer Prefix}${Target}${infer Subfix}` 
    ? `${Prefix}${Subfix}` : never;
​
​
type TestDelete = DeleteStr<'hello world', ' world'>; // hello

3.3.3 函数操作

针对函数操作,我们可以修改返回值,添加删除参数,修改参数类型等。

  • AddParameters

首先我们针对函数添加参数类型:

type AddParameters<Fun extends (...args: any[]) => any, Arg> = 
    Fun extends (...args: infer Args) => infer Return 
    ? (...args: [...Args, Arg]) => Return : never;
  • ChangeReturnType

我们还可以修改返回值类型

type ChangeReturnType<Fun extends (...args: any[]) => any, TeturnType> = 
    Fun extends (...args: infer Args) => infer Return 
    ? (...args: Args) => TeturnType : never;

4 递归判断

我们都知道一门语言必不可少的功能就是循环判断,只要有完备的循环判断,就可以构建出复杂的程序出来,那么做为图灵完备类型的Typescript类型系统,必定也会提供这两个功能:

  • extends:可以做为判断条件
  • 递归:递归模拟循环

前面我们已经用到很多以extends来模拟判断的语句,比如:

type AddParameters<Fun extends (...args: any[]) => any, Arg> = 
    Fun extends (...args: infer Args) => infer Return 
    ? (...args: [...Args, Arg]) => Return : never;

下面我们主要来玩递归,看看用递归我们能实现那些骚操作。

4.1 递归复用

递归(英语:Recursion),又译为递回,在数学计算机科学中,是指在函数的定义中使用函数自身的方法。递归一词还较常用于描述以自相似方法重复事物的过程。例如,当两面镜子相互之间近似平行时,镜中嵌套的图像是以无限递归的形式出现的。也可以理解为自我复制的过程。

上面是维基百科对递归的解释

4.1.1 数组递归

  • Includes

判断数组是否包含某一项是在JS最常用的功能,在Typescript系统中也是可以实现的。

type IsEqual<A, B> = (A extends B ? true : false) & (B extends A ? true : false);
​
type ArrayIncludes<Arr extends unknown[], Ele extends unknown> = 
    Arr extends [infer First, ...infer Rest] ?
  IsEqual<First, Ele> extends true 
    ? true 
    : ArrayIncludes<Rest, Ele>
    : false;
​
type IsArrayIncludes = ArrayIncludes<[1,2,3,4], 1> // true
  • Unique

我们能判断是否包含,就能做到去重操作

type IsEqual<A, B> = (A extends B ? true : false) & (B extends A ? true : false);
​
type ArrayUnique<Arr extends unknown[], Res extends unknown[] = []> = 
    Arr extends [infer First, ...infer Rest] ?
     ArrayIncludes<Res, First> extends false ?
        ArrayUnique<Rest, [ ...Res, First]>
        : ArrayUnique<Rest, Res>
    : Res;
​
type TestArrayUnique = ArrayUnique<[2,3,2,1]>; // [2,3,1]
  • ArrayCreate

因为在Typescrip类型系统中中是没有new的,所以我们想要构造一个数组就需要用到递归逐个添加元素:

type ArrayCreate<Len extends number, Ele extends unknown = unknown, Res extends unknown[] = []> = 
    Res['length'] extends Len ? 
    Res
    : ArrayCreate<Len, Ele, [...Res, Ele]>;
​
type TestArrayCreate = ArrayCreate<5, 10>; // [10, 10, 10, 10, 10]

4.1.2 字符串的递归操作

  • CamelCaseAll

在前面我们完重新构造时写过一个下划线转驼峰的类型,但是当时我们转了一层,hello_world可以转换为helloWorld,但是对于想is_need_update转换时就不能支持,因为最终转换出来为isNeed_update,如果想完成这样的转换,需要做递归操作

type CamelCaseAll<Str extends string> = 
    Str extends `${infer Left}_${infer Right}${infer Rest}` ?
    `${Left}${GetUppercase<Right>}${CamelCase<Rest>}` : Str;
​
type TestCamelCase = CamelCaseAll<'is_need_update'>; // isNeedUpdate
  • ReplaceAll

我们前面写过Replace,下面写一个增强版

type ReplaceAll<Str extends string, From extends string, To extends string> = 
    Str extends `${infer Prefix}${From}${Subfix}` ?
    `${Prefix}${To}${ReplaceAll<Subfix, From, To>}`
    : Str;
​
type TestReplaceAll = ReplaceAll<'hello world world', 'world', 'hello'>; // 'hello hello hello'

4.1.3 对象递归

我们知道Typescript我们提供了一个高级类型Readonly,就是将所有的属性变为Readonly,我们来试试实现这个功能

interface Person {
    name: string;
  age: number
}
​
​
type MyReadonly<T extends object> = {
  readonly [P in keyof T]: T[p]
}

上面我们已经实现了,但是如果我们对象有嵌套:

interface Person {
    name: string;
  age: number;
  addresses: {
    address: string
  }
}

此时,只能对外层进行修改,但是不能对内层进行修改

interface Person {
    readonly name: string;
  readonly age: number;
  readonly addresses: {
    address: string
  }
}

如果想做到深层修改,就需要对对象进行递归

type DeepReadonly<T extends object> = 
    T extends any ?
    {
        readonly [P in keyof T]:  T[P] extends object ? DeepReadonly<T[P]> : T[P]
    }
    : never

外面为什么要extends any,是为了触发执行,因为在typescript中是在访问时才触发更新。

5. 加减乘除

我们接下来玩typescript类型中最好玩的一部分,就是加减乘除,当然在typescript的类型系统中是没有办法做加减乘除的,那我们怎么实现呢,前面我们已经用了数组的length属性属性了

type A = [1,2,3]['length']; // 这样我们就可以做操作

那我们就可以用数组的length模拟加减乘除了。

首先我们看看加法怎么实现,前面我们实现过一个类型:ArrayCreate,就是用来创建数组,那么加法就好实现

type Add<A extends number, B extends number> = [...ArrayCreate<A>, ...ArrayCreate<B>]['length'];
  • 减法

减法的运算为:差 = 被减数 - 减数,转换为数组则为:差值部分 = 整体部分 - 减去部分,即 整体部分 = 减去部分 + 差值部分(被减数 = 减数 + 差)

type Subtract<A extends number, B extends number> = ArrayCreate<A> extends [...arr1: ArrayCreate<B>, ...arr2: infer Rest] ? Rest['length'] : never
  • 乘法

乘法的运算为:m + n,转换为数组思路为 n 个长度为 m 的数组相接的新数组的长度。

type Multiply<A extends number, B extends number, Pre extends unknown[] = []> = B extends 0 ? Pre['length'] : Multiply<A, Subtract<B, 1>, [...ArrayCreate<A, number>, ...Pre]>;
  • 除法

除法的运算为:m / n,转换为数组思路为长度为 m 的数组由多少个长度为 n 的数组组成。

type Divide<A extends number, B extends number, Pre extends unknown[] = []> = A extends 0 ? Pre['length'] : Divide<Subtract<A, B>, B, [unknown, ...Pre]>;

文件参考

TypeScript 类型体操通关秘籍

TypeScript 类型体操指北