从类型系统的角度看TS

前言

很多时候TS这门语言只被人看作是ES的类型增强版,然而在我看来TS作为ES的超集,ES语法可以看作是一个维度,而多出的类型系统则可以看作是另一个维度;也就是说如果把ES看作是一个一维编程语言,那么TS就是一个二维编程语言;

这篇文章尝试从多出来的一个维度来描述TS类型编程的独特性和一般性;所以,涉及各种类型的定义和用法不再过多阐述。

跟大多数编程语言一样,TS类型编程也遵循程序语言设计的一些原则。

程序设计语言的基本成分包括数据、运算、控制和传输等。

数据

数据是程序操作的对象,具有类型、名称、作用域、存储类别和生存期等属性,在程序运行过程中要为它分配内存空间。

本来习惯性地想把TS中所有用来存储类型的数据称之为“变量”,但是变量在严格意义上是可以在运行时改变值的,但是据观察,TS中的数据不允许多次赋值,所以也就不能称之为“变量”了,实际上所有的类型数据都是常量,不过声明合并那又是另一回事了;

1
2
3
4
5
6
7
8
9
10
11
type Test = {
name: string;
}

Test = {
age: number;
} // 这样的重新赋值,TS是不允许的,因为此时该标识符被认为是ES中的变量了

type Test = {
age: string; // 事实上同类型的标识符也不能重复声明
}

img

img

标识符

TS中的标识符就是用来命名类型,泛型,命名空间,模块等事物的特定字符串;经实验,标识符的命名规则如下:

  • 不允许数字开头;

    1
    2
    3
    4
    // ❌
    type 3d = {
    a: string;
    }
  • 不允许使用标点符号,下划线_和美元符号 $ 除外;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // ❌
    type d+ = {
    a: string;
    }

    // ✅
    type d_ = {
    a: string;
    }

    // ✅
    type $c$ = {
    name: string;
    }
  • 可以使用大小写英文字符,且区分大小写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // ✅
    type SOME_test = {
    a: string;
    }

    // 区分大小写
    type some_test = {
    name: string;
    }
  • 中文字符也是可以的;

    1
    2
    3
    4
    5
    6
    7
    8
    // ✅
    type 中文 = {
    name: string;
    }

    const a: 中文 = {
    name: 'ssss'
    }

TS类型系统的值有点特殊,因为其值对于ES而言是“类型”,也就是说类型系统中的值就是类型;正是由于值的特殊性,容易让人忘记实际上用来约束ES代码的类型只不过是类型系统中普普通通的值;

值类型

注:这里的值类型对于ES来说就是“类型的类型”了。

  • 原始类型stringnumberbooleannullundefinedsymbolbigintvoid;(本来理解原始类型应该类似于原子类型,应该是相互互斥的,但是nullundefined的存在打破了互斥关系……)
  • 顶部类型:类型系统中的根节点,即其他所有非顶部类型都是其子类型;如anyunknown
  • 底部类型:不存在对应值的一种类型,即不能兼容所有类型,包括自身;如never
  • 复合类型:复合类型则是对原始类型,顶部类型和底部类型的一种组合;比如对象类型(object及由interfacetype构建的对象类型),数组,元祖,枚举(enum关键字声明的类型),类(class关键字声明的类型)等;
  • 函数类型function关键字声明的类型(interface也能声明出函数的结构);
  • 字面量类型:这里的字面量就是指可以在ES中作为字面量使用的值;

数据结构类型

根据TS类型声明关键字的不同,可以把存储的数据结构分为以下几种:

  • interface
  • class
  • function
  • enum
  • type
  • namespace / moudle

运算

类比于一般编程语言中的数据基本运算,实际上TS类型系统也是有值运算的,只不过运算比较特别;

二元运算

  • 联合类型:使用|符号对两个值进行联合,得到的效果跟数组相似;
  • 交叉类型:使用&符号对两个值进行交叉;

三元运算

  • 条件运算:跟其他编程语言中一样,使用三元运算符?:来进行条件运算;

集合

注:本来是想把联合类型当作是类型系统中的数组结构的,但是仔细一想好像不严谨,因为联合类型自带去重,因此更像是集合结构;

在类型系统中,并没有显式的集合数据结构,但是可以把联合类型当作是类型系统中的集合,因为很多时候联合类型的表现跟其他编程语言中的集合是相似的;比如:

  • 组成联合类型的其中每个值可以看作是一个元素;

  • 可后续添加新元素和移除(移除指定元素需要函数来支持);

    1
    2
    3
    type A = number | string
    type B = A | boolean // 后续可以利用 | 符号继续添加
    type C = Exclude<A, number> // 可以利用Exclude函数进行去除指定元素

    img

    img

  • 添加元素时会自动去重

    1
    2
    type a = number | string | boolean
    type b = a | number // 由于联合类型a已经包含number,所以再次添加时会去重

    img

  • 可进行遍历,使用关键字in来对象类型中展开;

    1
    2
    3
    4
    5
    6
    7
    type keys = 'name' | 'age' | 'other'

    type ForStep<T extends keyof any> = {
    [P in T]: P | 'value'; // 利用in对集合进行遍历,然后可以对元素进行一些操作
    }

    type test = ForStep<keys>

    img

  • 可进行映射,利用分布式条件类型可对集合进行映射处理;

    1
    2
    3
    type a = number | string | boolean
    type b<T> = T extends string ? 'test' : T // 将元素string替换成字符串test
    type c = b<a> // 进行映射处理

    img

元组

元组实际上就是一组元素类型固定以及长度固定的数组类型,因此其很多操作都可以类比于JS中的数组来使用;

1
2
3
4
5
6
7
8
type T1 = [number, string, boolean]

type T5 = T1 extends {
0: number,
1: string,
2: boolean,
length: 3
} ? true : false // true

如上所示,在TS内部,可以近似地把一个元组类型当做是一个拥有length属性的对象类型(这种处理很自然,因为在JS中数组无非就是一种特殊的对象,其键名为数字),之所以说是近似,是因为在获取元组类型的key时,可以看到除了索引和length属性,还多了JS数组原型上的属性

1
2
3
type T1 = [number, string, boolean]

type T6 = keyof T1

img

不过也有一些技巧可以只获取到元组类型的数字索引

1
2
3
4
5
/**
* 获取元组类型的数字索引;
* @template Tuple 元组类型
*/
type GetTupleKeys<Tuple extends unknown[]> = Exclude<keyof Tuple, keyof []>
1
2
type T1 = [number, string, boolean]
type T7 = GetTupleKeys<T1> // "0" | "1" | "2"

基本操作

  • 索引访问:元组类型也能像一般对象那样的索引访问,当然也包含联合索引;

    1
    2
    3
    type T1 = [number, string, boolean]
    type T2 = T1[2] // boolean
    type T22 = T1[2 | 0] // number | boolean
  • 后置插入元素:使用的是TS里面的扩展运算符特性;

    1
    2
    3
    4
    type Push<Tuple extends unknown[], Element> = [...Tuple, Element]

    type T1 = [number, string, boolean]
    type T3 = Push<T1, string[]> // [number, string, boolean, string]
  • 前置插入元素:原理跟后置插入同理;

    1
    2
    3
    4
    type Prepend<Tuple extends unknown[], Element> = [Element, ...Tuple]

    type T1 = [number, string, boolean]
    type T4 = Prepend<T1, number[]> // [number[], number, string, boolean]
  • 合并两个元组

    1
    2
    3
    4
    type Concat<T1 extends unknown[], T2 extends unknown[]> = [...T1, ...T2]

    type T1 = [number, string, boolean]
    type T12 = Concat<T1, [string[], boolean[]]> // [number, string, boolean, string[], boolean[]]
  • 遍历操作:原理实际上跟遍历对象类型一样;

    1
    2
    3
    4
    type T1 = [number, string, boolean]
    type T13 = {
    [K in GetTupleKeys<T1>]: (params: T1[K]) => void
    }

    img

扩展操作

所谓的扩展操作就是指需要一些技巧来中转达到目的,而没有快捷的语法支持,且在类型系统中使用场景可能比较有限;

1
2
3
4
5
6
7
/**
* 将联合类型转为对应的交叉函数类型
* @template U 联合类型
*/
type UnionToInterFunction<U> =
(U extends any ? (k: () => U) => void : never) extends
((k: infer I) => void) ? I : never
1
2
3
4
5
/**
* 获取联合类型中的最后一个类型
* @template U 联合类型
*/
type GetUnionLast<U> = UnionToInterFunction<U> extends { (): infer A; } ? A : never
  • 删除最后一个元素

    1
    2
    3
    type Pop<Tuple extends unknown[], Index = GetTupleKeys<Tuple>, RemoveKey = GetUnionLast<Index>> = {
    [I in Exclude<Index, RemoveKey>]: Tuple[I];
    }
    1
    2
    3
    type T1 = [number, string, boolean]
    type T8 = Pop<T1>
    type T9 = T8[2] // Property '2' does not exist on type 'Pop<T1, "0" | "1" | "2", "2">'

    img

  • 删除指定位置元素:这种方式实际上仅仅是把指定索引位置的元素去除,而其他位置的元素依然保持在原索引位置,因此并非真正意义上像JS数组中的删除一样;

    1
    2
    3
    type RemoveElementFromIndex<Tuple extends unknown[], Pos extends string = '0', Index = GetTupleKeys<Tuple>> = {
    [I in Exclude<Index, Pos>]: Tuple[I];
    }
    1
    2
    3
    type T1 = [number, string, boolean]
    type T10 = RemoveElementFromIndex<T1, '1'>
    type T11 = T10[1] // Property '1' does not exist on type 'RemoveElementFromIndex<T1, "1", "0" | "1" | "2">'

    img

对象

类型系统中的对象就是对象类型(可以由interfacetype甚至是class创建得到),对象类型具有跟ES中对象相似的结构,只不过键值是类型而已;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface A {
name: string;
age: number;
}

type B = {
readonly ok: boolean;
size: number;
other?: string[];
}

declare class AAA {
name: string

private some (word: string): Promise<boolean>
}

索引访问

类型系统中的对象也是支持通过索引来访问相应属性的值,只不过只能通过[key](中括号)的方式进行访问,不能像ES中那样通过.(点符号)来访问!

1
type C = A['age'] | B['ok'] | AAA['some'] // 没错,class也能当做对象进行访问

img

对象中属性的修饰符(或者类成员的修饰符)则不会被带入到键值,也就是通过索引访问访问只能得到属性值;不过如果是可选属性,那么属性值默认会与undefined进行联合;

1
2
3
4
type D = {
extra: B['other']; // 访问可选属性
some: B['ok']; // 访问readonly属性
}

img

img

不仅如此,索引访问还支持使用集合(也就是联合类型)来进行一次访问多个属性值,且将多个属性值进行联合,最后得到一个集合;

1
type E = B['ok' | 'other'] // 还可以用联合类型来访问多个属性值

img

函数

注:泛型类型(generic type)指的就是TS中带泛型的类型。

同样地,类型系统中并没有显式声明函数的语法;但是泛型类型完全可以当作是类型系统中的函数,因为泛型类型可以接受参数并返回不同的值,也就是说泛型类型具备入参(输入)和出参(输出)的形式。如常用的官方内置泛型类型Pick

1
2
3
type Pick<T, U extends keyof T> = {
[K in U]: T[K];
}

上面这段代码就可以理解为一个定了两个参数的函数,这个函数会对第二个参数进行遍历,然后构成一个新的对象类型,并返回这个值;

递归

没错,类型系统中的“函数”也能够调用自身,如:

1
type DeepReadonly<T> = { readonly [P in keyof T]: DeepReadonly<T[P]> }

控制语句

类型系统中并没有其他编程语言那种使用独立的关键字和语法结构显式的控制语句,但是也有某些关键字能够起到类似的作用。这个局面个人猜测可能与TS本身就包含了ES的全部语法,导致使用独立关键字和语法结构来定义控制语句会出现冲突(除非定义一套互斥的语法);

条件语句

T extends U ? True分支 : False分支

使用extends关键字来获取条件结果,三元运算符?:来控制条件分支,这就是类型系统中特有的条件语句,当然,条件语句也是可以嵌套的。

循环语句

[K in Union Type]

使用关键字in可以在对象类型索引签名中遍历集合(也就是联合类型),并能够得到集合中的每个元素,进行下一步控制;

相关文档