TS类型系统:类型编程

162 阅读5分钟

为什么TS要支持类型编程

首先来思考一个场景:如果有一个返回对象某个属性值的函数,类型该怎么写呢?

使用泛型?

function getPropValue<T>(obj: T, key): key对应的属性值类型 {
  return obj[key];
}

拿到了T,但是并不能拿到它的属性和属性值。

在 Java 里面,拿到了对象的类型就能找到它的类,进一步拿到各种信息,所以类型系统支持泛型就足够了。

但是在 JavaScript 里面,对象可以字面量的方式创建,还可以灵活的增删属性,拿到对象并不能确定什么,所以要支持对传入的类型参数做进一步的处理,于是就有了类型编程。

于是上述场景我们可以这样写:

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

这里的 keyof T、T[Key] 就是对类型参数 T 的类型运算。

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

对类型参数的编程是 TypeScript 类型系统最强大的部分,可以实现各种复杂的类型计算逻辑,是它的优点。但同时也被认为是它的缺点,因为除了业务逻辑外还要写很多类型逻辑。

不过,这种复杂度是不可避免的,因为 JS 本身足够灵活,要准确定义类型那类型系统必然也要设计的足够灵活。

TS中的额外类型

TS 类型系统中肯定要把 JS 的运行时类型拿过来,也就是 number、boolean、string、object、bigint、symbol、undefined、null 这些类型,还有就是它们的包装类型 Number、Boolean、String、Object、Symbol。

复合类型方面,JS 有 class、Array,这些 TypeScript 类型系统也都支持,但是又多加了三种类型:元组(Tuple)、接口(Interface)、枚举(Enum)。

元组

元组(Tuple)就是元素个数和类型固定的数组类型:

type Tuple = [number, string];

接口

接口(Interface)可以描述函数、对象、构造器的结构:

对象

interface IPerson {
    name: string;
    age: number;
}

class Person implements IPerson {
    name: string;
    age: number;
}

const obj: IPerson = {
    name: 'guang',
    age: 18
}

函数

interface SayHello {
    (name: string): string;
}

const func: SayHello = (name: string) => {
    return 'hello,' + name
}

构造器

interface PersonConstructor {
    new (name: string, age: number): IPerson;
}

function createPerson(ctor: PersonConstructor):IPerson {
    return new ctor('guang', 18);
}

对象类型、class 类型在 TypeScript 里也叫做索引类型,也就是索引了多个元素的类型的意思。对象可以动态添加属性,如果不知道会有什么属性,可以用可索引签名:

interface IPerson {
    [prop: string]: string | number;
}
const obj:IPerson = {};
obj.name = 'guang';
obj.age = 18;

总之,接口可以用来描述函数、构造器、索引类型(对象、class、数组)等复合类型

枚举

枚举(Enum)是一系列值的复合:

enum Transpiler {
    Babel = 'babel',
    Postcss = 'postcss',
    Terser = 'terser',
    Prettier = 'prettier',
    TypeScriptCompiler = 'tsc'
}

const transpiler = Transpiler.TypeScriptCompiler;

字面量类型

此外,TypeScript 还支持字面量类型,也就是类似 1111、'aaaa'、{ a: 1} 这种值也可以做为类型。

其中,字符串的字面量类型有两种,一种是普通的字符串字面量,比如 'aaa',另一种是模版字面量,比如 aaa${string},它的意思是以 aaa 开头,后面是任意 string 的字符串字面量类型。

所以想要约束以某个字符串开头的字符串字面量类型时可以这样写:

function beginWithA(str: `A${string}`) {}

特殊类型

还有四种特殊的类型:void、never、any、unknown:

  • never 代表不可达,比如函数抛异常的时候,返回值就是 never。
  • void 代表空,可以是 undefined 或 never。
  • any 是任意类型,任何类型都可以赋值给它,它也可以赋值给任何类型(除了 never)。
  • unknown 是未知类型,任何类型都可以赋值给它,但是它不可以赋值给别的类型。

类型装饰

除了描述类型的结构外,TypeScript 的类型系统还支持描述类型的属性,比如是否可选,是否只读等:

interface IPerson {
    readonly name: string;
    age?: number;
}

type tuple = [string, number?];

类型运算

条件:extends ?

TypeScript 里的条件判断是 extends ? :,叫做条件类型(Conditional Type)

类型运算逻辑都是用来做一些动态的类型的运算的,也就是对类型参数的运算

type isTwo<T> = T extends 2 ? true: false;

type res = isTwo<1>;
type res2 = isTwo<2>;

这种类型也叫做高级类型。

高级类型的特点是传入类型参数,经过一系列类型运算逻辑后,返回新的类型。

推导:infer

如何提取类型的一部分呢?答案是 infer。

比如提取元组类型的第一个元素:

type FirstType<Turple extends unknown[]> = Turple extends [infer T, ...infer E]
  ? T
  : never;
type res = FirstType<[string, number, boolean]>

联合

type Union = 1 | 2 | 3;

交叉:&

代表对类型做合并。注意,同一类型可以合并,不同的类型没法合并,会被舍弃:

type ObjType = {a: number } & {c: boolean};

映射类型

对象、class 在 TypeScript 对应的类型是索引类型(Index Type),那么如何对索引类型作修改呢?

答案是映射类型。

映射类型就相当于把一个集合映射到另一个集合

type MapType<T> = {
  [Key in keyof T]?: T[Key]
}

keyof T 是查询索引类型中所有的索引的联合类型,叫做索引查询。

T[Key] 是取索引类型某个索引的值,叫做索引访问。

in 是用于遍历联合类型的运算符

比如我们把一个索引类型的值变成 3 个元素的数组:

type MapTypeArr<T> = {
  [K in keyof T]: [T[K], T[K], T[K]]
}
type MapResArr = MapTypeArr<{ a: string, b: number }>

当然我们也可以修改索引

type MapTypeIndex<T> = {
  [K in keyof T as `${K & string}${K & string}`]: [T[K], T[K], T[K]]
}
type MapResIndex = MapTypeIndex<{ a: string, b: number }>

这里的 & string 可能大家会迷惑,解释一下:

因为索引类型(对象、class 等)可以用 string、number 和 symbol 作为 key,这里 keyof T 取出的索引就是 string | number | symbol 的联合类型,和 string 取交叉部分就只剩下 string 了。就像前面所说,交叉类型会把同一类型做合并,不同类型舍弃。