Typescript 类型系统简述

  • Typescript 的类型是支持定义 “函数定义” 的
  • Typescript 的类型是支持 “条件判断” 的
  • Typescript 的类型是支持 “数据结构” 的
  • Typescript 的类型是支持 “作用域” 的
  • Typescript 的类型是支持 “递归” 的

Typescript 的类型是支持定义 “函数定义” 的

Typescript 中类型系统中的函数被称作 泛型操作符,其定义的简单的方式就是使用 type 关键字:

定义 泛型操作符

  1. // 这里我们就定义了一个最简单的泛型操作符
  2. type foo<T> = T;

这里的代码如何理解呢,其实这里我把代码转换成大家最熟悉的 Javascript 代码其实就不难理解了:

// 把上面的类型代码转换成 `JavaScript` 代码
function foo(T) {
  return T
}

那么看到这里有同学心里要犯嘀咕了,心想你这不是忽悠我嘛?这不就是 Typescript 中定义类型的方式嘛?这玩意儿我可太熟了,这玩意儿不就和 interface 一样的嘛,我还知道 Type 关键字和 interface 关键字有啥细微的区别呢!

嗯,同学你说的太对了,不过你不要着急,接着听我说,其实类型系统中的函数还支持对入参的约束。

入参约束

// 这里我们就对入参 T 进行了类型约束
type foo<T extends string> = T;

那么把这里的代码转换成我们常见的 Typescript 是什么样子的呢?

function foo(T: string) {
  return T
}

当然啦我们也可以给它设置默认值:

设置默认值

// 这里我们就对入参 T 增加了默认值
type foo<T extends string = 'hello world'> = T;

那么这里的代码转换成我们常见的 Typescript 就是这样的:

function foo(T: string = 'hello world') {
  return T
}

Typescript 的类型是支持 “条件判断” 的

条件判断是编程语言中最基础的功能之一,也是我们日常撸码过程成最常用的功能,无论是 if else 还是 三元运算符,相信大家都有使用过。

那么在 Typescript 类型系统中的类型判断要怎么实现呢?

其实这在 Typescript 官方文档被称为 条件类型(Conditional Types),定义的方法也非常简单,就是使用 extends 关键字。

T extends U ? X : Y;

这里相信聪明的你一眼就看出来了,这不就是 三元运算符 嘛!是的,而且这和三元运算符的也发也非常像,如果 T extends Utrue 那么 返回 X ,否则返回 Y
结合之前刚刚讲过的 “函数”,我们就可以简单的拓展一下:

type num = 1;
type str = 'hello world';
type IsNumber<N> = N extends number ? 'yes, is a number' : 'no, not a number';
type result1 = IsNumber<num>; // "yes, is a number"
type result2 = IsNumber<str>; // "no, not a number"

这里我们就实现了一个简单的带判断逻辑的函数。

Typescript 的类型是支持 “数据结构” 的

让我们更加深入地了解一下 联合类型(Union Types):

如何遍历 联合类型(Union Types) 呢?
**
Typescript 类型系统中,用 in 关键字来遍历。

type key = 'vue' | 'react';
type MappedType = { [k in key]: string } // { vue: string; react: string; }

你看,通过 in 关键字,我们可以很容易地遍历 联合类型(Union Types),并对类型作一些变换操作。
但有时候并不是所有所有 联合类型(Union Types) 都是我们显式地定义出来的。

我们想动态地推导出 联合类型(Union Types) 类型,有哪些方法呢?
**
可以使用 keyof 关键字动态地取出某个键值对类型的 key

interface Student {
  name: string;
  age: number;
}
type studentKey = keyof Student; // "name" | "age"

同样的我们也可以通过一些方法取出 元组类型 子类型:

type framework = ['vue', 'react', 'angular'];
type frameworkVal1 = framework[number]; // "vue" | "react" | "angular"
type frameworkVal2 = framework[any]; // "vue" | "react" | "angular"

既然说 联合类型(Union Types) 可以批量操作类型,「那我想把某一组类型批量映射成另一种类型,该怎么操作呢」

其实分析一下上面那个需求,不难看出,这个需求其实和数组的 map 方法有点相似

那么如何实现一个操作 联合类型(Union Types) 的 map 函数呢?
// 这里的 placeholder 可以键入任何你所希望映射成为的类型
type UnionTypesMap<T> = T extends any ? 'placeholder' : never;

其实这里聪明的同学已经看出来,我们只是利用了 条件类型(Conditional Types),使其的判断条件总是为 true,那么它就总是会返回左边的类型,我们就可以拿到 泛型操作符 的入参并自定义我们的操作。

让我们趁热打铁,再举个具体的栗子:把 「联合类型(Union Types)」 的每一项映射成某个函数的 「返回值」

type UnionTypesMap2Func<T> = T extends any ? () => T : never;
type myUnionTypes = "vue" | "react" | "angular";
type myUnionTypes2FuncResult = UnionTypesMap2Func<myUnionTypes>;
// (() => "vue") | (() => "react") | (() => "angular")

Typescript 的类型是支持 “作用域” 的


全局作用域

Typescript 的类型系统中,也是支持 「全局作用域」 的。换句话说,你可以在没有 「导入」 的前提下,在 「任意文件任意位置」 直接获取到并且使用它。

通常使用 declare 关键字来修饰,例如我们常见的 图片资源 的类型定义:

declare module '*.png';
declare module '*.svg';
declare module '*.jpg';

当然我们也可以在 「全局作用域」 内声明一个类型:

declare type str = string;
declare interface Foo {
  propA: string;
  propB: number;
}

需要注意的是,如何你的模块使用了 export 关键字导出了内容,上述的声明方式可能会失效,如果你依然想要将类型声明到全局,那么你就需要显式地声明到全局:

declare global {
  const ModuleGlobalFoo: string;
}


模块作用域

就像 nodejs 中的模块一样,每个文件都是一个模块,每个模块都是独立的模块作用域。这里模块作用域触发的条件之一就是使用 export 关键字导出内容。

每一个模块中定义的内容是无法直接在其他模块中直接获取到的,如果有需要的话,可以使用 import 关键字按需导入。

泛型操作符作用域&函数作用域

泛型操作符是存在作用域的,还记得这一章的第一节为了方便大家理解,我把泛型操作符类比为函数吗?既然可以类比为函数,那么函数所具备的性质,泛型操作符自然也可以具备,所以存在泛型操作符作用域自然也就很好理解了。
这里定义的两个同名的 T 并不会相互影响:

type TypeOperator<T> = T;
type TypeOperator2<T> = T;

上述是关于泛型操作符作用域的描述,下面我们聊一聊真正的函数作用域:

「类型也可以支持闭包」**:

function Foo<T> () {
  return function(param: T) {
    return param;
  }
}
const myFooStr = Foo<string>();
// const myFooStr: (param: string) => string
// 这里触发了闭包,类型依然可以被保留
const myFooNum = Foo<number>();
// const myFooNum: (param: number) => number
// 这里触发了闭包,类型也会保持相互独立,互不干涉


Typescript 的类型是支持 “递归” 的

Typescript 中的类型也是可以支持递归的,递归相关的问题比较抽象,这里还是举例来讲解,同时为了方便大家的理解,我也会像第一节一样,把类型递归的逻辑用 Javascript 语法描述一遍。
首先来让我们举个栗子:

假如现在需要把一个任意长度的元组类型中的子类型依次取出,并用 & 拼接并返回。

这里解决的方法其实非常非常多,解决的思路也非常非常多,由于这一小节讲的是 「递归」,所以我们使用递归的方式来解决。废话不罗嗦,先上代码:

// shift action
type ShiftAction<T extends any[]> = ((...args: T) => any) extends ((arg1: any, ...rest: infer R) => any) ? R : never;
type combineTupleTypeWithTecursion<T extends any[], E = {}> = {
  1: E,
  0: combineTupleTypeWithTecursion<ShiftAction<T>, E & T[0]>
}[T extends [] ? 1 : 0]
type test = [{ a: string }, { b: number }];
type testResult = combineTupleTypeWithTecursion<test>; // { a: string; } & { b: number; }

看到上面的代码是不是一脸懵逼?没关系,接下来我们用普通的 Typescript 代码来 “翻译” 一下上述的代码。

function combineTupleTypeWithTecursion(T: object[], E: object = {}): object {
  return T.length ? combineTupleTypeWithTecursion(T.slice(1), { ...E, ...T[0] }) : E
}
const testData = [{ a: 'hello world' }, { b: 100 }];
// 此时函数的返回值为 { a: 'hello world', b: 100 }
combineTupleTypeWithTecursion(testData);

看到这儿,相信聪明的同学一下子就懂了,原来类型的递归与普通函数的递归本质上是一样的。如果触发结束条件,就直接返回,否则就一直地递归调用下去,所传递的第二个参数用来保存上一次递归的计算结果。
当然熟悉递归的同学都知道,常见的编程语言中,递归行为非常消耗计算机资源的,一旦超出了最大限制那么程序就会崩溃。同理类型中的递归也是一样的,如果递归地过深,类型系统一样会崩溃,所以这里的代码大家理解就好,尽量不要在生产环境使用哈。

常见类型推导实现逻辑梳理

  • 类型的传递(流动)
  • 类型的过滤与分流


类型的传递(流动)

类型是具备流动性的,结合 「响应式编程」 的概念其实很容易理解。这一小节我们将列举几个常见的例子,来和大家具体讲解一下。

有编程经验的同学都知道,数据是可以被传递的,同理,类型也可以。

你可用 type 创建一个类型指针,指向对应的类型,那么就可以实现类型的传递,当然你也可以理解为指定起一个别名,或者说是拷贝,这里见仁见智,但是通过上述方法可以实现类型的传递,这是显而易见的。

type RawType = { a: string, b: number };
// 这里就拿到了上述类型的引用
type InferType = RawType; // { a: string, b: number };

同样,类型也可以随着数据的传递而传递:

var num: number = 100;
var num2  = num;
type Num2Type = typeof num2; // number

也正是依赖这一点,Typescript 才得以实现 「类型检查」「定义跳转」 等功能。
到这里熟悉 「流式编程」 的同学就要举手了:你光说了类型的 「传递」「输入」「输出」,那我如果希望在类型 「传递」 的过程中对它进行操作,该怎么做呢?同学你不要急,这正是我下面所想要讲的内容。

类型的过滤与分流

翻看一下常用 「函数式编程」 的库,不管是 RamdaRXJS 还是我们耳熟能详的 lodashunderscore,里面一定有一个操作符叫作 filter,也就是对数据流的过滤。
这个操作符的使用频率一定远超其他操作符,那么这么重要的功能,我们在类型系统中该如何实现呢?
要解决这个问题,这里我们先要了解一个在各大 技术社区/平台 搜索频率非常高的一个问题:
「TypeScript中 的 never 类型具体有什么用?」
既然这个问题搜索频率非常之高,这里我也就不重复作答,有兴趣的同学可以看一下尤大大的回答:TypeScript中的never类型具体有什么用?- 尤雨溪的回答 - 知乎。

这里我们简单总结一下:

  1. never 代表空集。
  2. 常用于用于校验 “类型收窄” 是否符合预期,就是写出类型绝对安全的代码。
  3. never 常被用来作 “类型兜底”。

当然上面的总结并不完整,但已经足够帮助理解本小节内容,感兴趣的同学可以自行查阅相关资料。
上面提到了 “类型收窄”,这与我们的目标已经十分接近了,当然我们还需要了解 never 参与类型运算的相关表现:

type NeverTest = string | never // stirng
type NeverTest2 = string & never // never

重要的知识出现了:T | never,结果为 T
看到这里,相信聪明的同学们已经有思路了,我们可以用 never 来过滤掉 联合类型(Union Types) 中不合期望的类型,其实这个 「泛型操作符」 早在 Typescript 2.8 就已经被加入到了官方文档中了。

/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T;

相信经过这么长时间的学习,看到这里你一定很容易就能这种写法的思路。
好了,讲完了 「过滤」,我们再来讲讲 「分流」。类型 「分流」 的概念其实也不难理解,这个概念常常与逻辑判断一同出现,毕竟从逻辑层面来讲,联合类型(Union Types) 本质上还是用来描述 「或」 的关系。同样的概念如果引入到 「流式编程」 中,就自然而然地会引出 「分流」。换成打白话来讲,就是不同数据应被分该发到不同的 「管道」 中,同理,类型也需要。
那么这么常用的功能,在 Typescript 中如何处理呢?其实这种常见的问题,官方也非常贴心地为我们考虑到了,那就是:类型守卫(Type guard)。网上对 类型守卫(Type guard) 有讲解的文章非常的多,这里也不作赘述,有兴趣的同学可以自行搜索学习。我们这里用一个简单的栗子简单地演示一下用法:

function foo(x: A | B) {
  if (x instanceof A) {
    // x is A
  } else {
  // x is B
  }
}


「可以触发类型守卫的常见方式有」typeofinstanceofin=====!=!== 等等。
当然在有些场景中,单单通过以上的方式不能满足我们的需求,该怎么办呢?其实这种问题,官方也早已经帮我考虑到了:
使用 is 关键字自定义 类型守卫(Type guard)**。

// 注意这里需要返回 boolean 类型
function isA(x): x is A {
  return true;
}
// 注意这里需要返回 boolean 类型
function isB(x): x is B {
  return x instanceof B;
}
function foo2(x: unknown) {
  if (isA(x)) {
    // x is A
  } else {
    // x is B
  }
}

参考文档:https://mp.weixin.qq.com/s/lgwS59zY4iFFBbCcyvF3CQ
扩展阅读:
TypeScript 技巧拾遗
Typescript 中的 interface 和 type 到底有什么区别
技术胖TypeScript图文视频教程 最污的技术课
https://github.com/semlinker/awesome-typescript