一小时快速回顾 TypeScript

287 阅读26分钟

一、什么是 TypeScript?

我正在参加「掘金·启航计划」

image-20230511161419210

TypeScript 是一款由微软开发的带有类型语法的 JavaScriptTypeScript 是一种基于 JavaScript强类型静态编程语言

什么是强类型编程语言?

强类型编程语言在编译时或运行时强制执行类型检查,并要求在变量声明和使用时指定明确的数据类型。这就意味着变量必须被赋予与其数据类型相同的值,否则必须手动进行强制类型转化之后再赋值。如果出现类型不匹配的情况,编译器或运行时的系统会产生一个类型错误并停止执行

与其相对的是弱类型编程语言。弱类型编程语言会进行隐式转换和自动类型推断,在变量声明和使用时不需要指定数据类型

举个例子🌰:

let num = 10;
num = 20; // 在 js 中合法
num = "hello" // 在 js 中也合法

// 定义一个函数,接受两个参数
function add(x, y) {
  return x + y;
}

// 下面的调用是合法的,因为 JavaScript 不检查参数类型
let result = add(1, "2"); // 结果为 "12",因为字符串类型会自动转换为数字类型进行相加
let num: number = 10;
num = 20; // 在 ts 中合法
num = "hello" // 在 ts 中报错

// 定义一个函数,接受两个数字类型的参数,返回值也是数字类型
function add(x: number, y: number): number {
  return x + y;
}

// 下面的调用是非法的,因为参数类型不匹配
let result = add(1, "2"); // TypeScript 编译器会提示类型错误

什么是静态类型编程语言?

静态类型是在编译时检查类型的类型系统,其优点为编译器在编译时就能检查并发现类型错误,提高代码质量和可维护性。

与之相对的是动态类型,动态类型是在运行时检查类型的类型系统,优点是灵活性更高,编写时不需要过多关注类型问题,可以更专注于业务逻辑,且不用每次都必须编译整个程序。

强弱类型和静动态类型并不是互斥的概念。

二、为什么是 TypeScript?

最近,软件开发设计公司 The Software House 针对 2022 年前端市场状态的调查显示,84% 的受访者都在使用 TypeScript43% 的受访者甚至认为 TypeScript 将超越 JavaScript 成为前端开发的主要语言。

typeScript 主要有以下三个方面的特点:

1. 💪 健壮性

在《Top 10 JavaScript errors from 1000+ projects》 中,最常见的错误就是 Cannot read property 'xxx' of undefinedundefined is not a function 等,其中有 8 个都是我们可以通过 TS 避免的。这些错误就是我们过度依赖 JavaScript 灵活性的后果,一旦它们被触发,就会导致你的页面白屏、卡死,甚至崩溃。TypeScript 增强了项目代码的健壮性

2. 👍 可预见性

TypeScript 类型代码,让你有底气大胆地进行各种逻辑操作,不需要先把这些值都打印出来确认一遍,就会自动地推导出变量最终的类型。**你只需要确保类型符合,最终的结果就是符合你预期的。**而在你敲击下 . 来访问一个变量的属性时,TypeScript 也会将所有的属性展示出来供你挑选。

image-20230426222530280

3.🤝 门槛性

TypeScript 是一个高级开发人员必会的技能之一,同时也是能熟练使用 ts 的竞争力远比只能使用 js 高,尤其是大厂几乎是纯 100% 的使用率,很多开源库也都是 ts 编写,对于阅读源码也有很大的帮助。

但是,关于 TypeScript 的质疑却一直没有减少,比如:

  • TypeScript 限制了 JavaScript 的灵活性
  • TypeScript 并不能提高应用程序的性能
  • TypeScript 开发需要更多额外的类型代码

如果不使用 TS 能够得到以下几方面的优势:

  1. 减少了构建时间
  2. 发布代码变小了
  3. 写的代码大大减少了

我个人的看法是代码真正的健壮性和可维护性并不是完全由类型来决定的,只要是代码逻辑写得好,不论是 JS 代码还是 TS 代码,都能写出健壮且高可维护的代码。如果只是以是否使用 TS 来判断可维护性,那么那么多静态强类型语言难道就不会写出💩山来吗?

三、开发准备

在学习 TypeScript 之前,我们需要先搭建 TypeScript 环境。一个舒适且顺手的开发环境不仅有利于我们学习,并且能大大提高我们的开发效率。

VS Code 配置和插件

对于 VS Code 大家已经不陌生了,它本身就是用 TypeScript 编写的一款 IDE,因此对 TypeScript 的支持非常好。

image-20230510190038068

我们首先需要给 VS Code 添加一些配置和插件。

第一个是 TypeScript Importer 这个插件会收集项目中所有的类型定义,在你敲出 : 时进行补全提示,并且会自动将这个类型导入。

第二个是 move TS 这个插件的作用就是当你移动了 TypeScript 文件的时候,会自动更新引用导入的路径。比如从home/project/learn-interface.ts 修改成 home/project/interface-notes/interface-extend.ts

第三个是 errorLens 改插件会将你的 VS Code 底部问题栏的错误下直接显示到代码文件中的对应位置。

最后一个是 Pretty TypeScript Errors 可以帮助你人性化 TypeScript 的错误提示。

之后我们还需要添加一些配置:

在设置里面搜索 TypeScript Inlay Hints 匹配出来的就是 ts 提示相关的配置了。推荐开启的有如下几个:

  • Enum Member Values
  • Function Like Return Types
  • Parameter Names
  • Variable Types

具体的信息可以直接在设置中查看。

开发环境

首先我们需要全局安装 TypeScript

npm i typescript -g

接着我们创建一个 index.ts 文件,输入以下内容:

const content: string = "hello typescript";

对于 TS 代码是不能直接在浏览器中运行的,所以我们需要先将其编译成 JS 代码,使其可以被浏览器理解。我们可以通过 tsc index.ts 命令来编译 .ts 文件:

// helloworld.ts => helloworld.js
const content = "hello typescript";

如果我们想要直接在服务端执行 TypeScript 文件,我们需要 ts-node 以及 ts-node-dev 这类工具,它们能直接执行 ts 文件,并且支持监听和文件的重新执行。

首先我们将其安装到全局:

npm i ts-node -g

然后创建另一个 ts-demo 的文件夹。

在文件夹下执行命令:

tsc --init

这样会初始化 TypeScript 的配置文件:tsconfig.json

接着我们在该目录下创建一个 index.ts 文件。

console.log("hello typescript");

再使用 ts-node 来执行:

ts-node index.ts

如果顺利的话,就会输出对应的语句了。

image-20230511183029410

ts-node 本身并不支持自动监听文件变化然后重新执行,而这一功能又是刚需,比如用 ts 进行一些服务端的 api 开发。我们可以借助 ts-node-dev 库来实现这一能力。

首先先全局安装:

npm i ts-node-dev -g

之后我们可以使用 tsnd 这一简称来执行命令:

tsnd --respawn index.ts

其中 --respawn 表示启动监听。

当我们修改文件的时候无需重新运行命令,只要保存了就会自动监听文件的改变并重新执行。

image-20230511183555964

对于一些简单的纯 TS 代码,能检查类型错误,并快速调整 tsconfig,我们可以通过 TS Playground 来进行编写。

四、类型基础

原始类型

首先我们回顾一下在 JavaScript 中有哪些原始数据类型,除了最常见的 number / string / boolean / null / undefined 之外,ES2015ES2020 又分别引入了 symbolbigint 两个新原始类型。

const name: string = 'lanshan';
const age: number = 24;
const male: boolean = false;
const undef: undefined = undefined;
const nul: null = null;
const bigintVar1: bigint = 9007199254740991n;
const bigintVar2: bigint = BigInt(9007199254740991);
const symbolVar: symbol = Symbol('unique');

JS 中的数据类型在 TS 中都有,其中 undefinednull 是其他类型的子集,在 strictNullChecks 不为 true 的情况下都可以赋值给其他类型。除此之外,还有 voidnever 等类型。

void 类型可以用来表述一个内部没有显式 return 的函数返回值。例如:

function func1(): void {}
function func2(): void {
  return;
}

这两个函数都会被隐式推导为 void

需要注意的是 undefined 能够被赋值给 void 类型的变量,所以下面函数的返回值类型既可以写成 void 也可以写成 undefined

function func3(): void {
  return undefined;
}
function func4(): undefined {
  return undefined;
}

在严格空检查模式(tsconfig.jsonstrictNullCheckstrue)下, nullundefined 值都不属于任何一个类型,它们只能赋值给自己这种类型或者 any (有一个例外,undefined 也可以赋值给 void)。

数组

数组在 JS 中属于对象类型,在 TS 中,我们将其与对象先分开来研究。

TS 中有两种方法来声明一个数组类型:

// 都是表示数组元素全为字符串的数组
const arr1: string[] = [];
const arr2: Array<string> = [];

这两种方法都是等价的,但我们一般是以前者为主。

元组

元组是一种特殊的数组,其长度固定,可以给 TS 提供如数组越界访问等类型报错。如:

const arr3: string[] = ['lan', 'shan'];
// 显式越界
console.log(arr3[599]);
// 隐式越界
const [ele1, ele2, ele3, ...other] = arr3;

对于数组,这种情况 TS 是不会给我们报错的,但是如果我们通过元组来定义:

const arr4: [string, string] = ['lan', 'shan'];
console.log(arr4[599]);

就会给我们类型提示的错误:长度为“2”的元组类型“[string, string]”在索引“599“处没有元素

同时元组也支持在某个位置上的可选成员:

const arr5: [string, number?, boolean?] = ['lanshan'];

这时元组的长度类型也会变成也会变为 1 | 2 | 3

type TupleLength = typeof arr5.length; // 1 | 2 | 3

你可能会觉得元组的类型可读性并不好,比如 [string, number, boolean] 你不能直接知道这三个元素都代表什么,还不如用对象的形式。但是我们可以给元组中的元素打上类似属性的标签:

const arr7: [name: string, age: number, male: boolean] = ['lanshan', 599, true];

对象

我们可以通过 interface 声明一个对象结构,命名通常以 I 开头,来表示 Interface

interface IDescription {
  name: string;
  age: number;
  male: boolean;
}

const obj1: IDescription = {
  name: 'lanshan',
  age: 599,
  male: true,
};

对于普通描述来说,每个属性的值必须一一对应到接口的属性类型,且不能有多的属性,也不能有少的属性。

除此之外,我们还可以对属性进行修饰,常见的修饰包括可选(optional)和只读(readonly)这两种。

如下是可选修饰的示例:

interface IDescription {
  name: string;
  age: number;
  male?: boolean;
  func?: Function;
}

const obj2: IDescription = {
  name: 'lanshan',
  age: 599,
  male: true,
  // 无需实现 func 也是合法的
};

这种情况下 obj2.maleobj2.func 会联合 undefined 类型,如 obj2.male 的类型为实际上为 boolean | undefined 表示 booleanundefined 类型都符合。

除了可选修饰,还有只读修饰:

interface IDescription {
  readonly name: string;
  age: number;
}

const obj3: IDescription = {
  name: 'lanshan',
  age: 599,
};

// 无法分配到 "name" ,因为它是只读属性
obj3.name = "蓝山";

在数组和元组中,也可以进行只读修饰,但是只能将整个数组或元组标记为只读,而不能只标记某一项,且一旦标记为只读,那么就不在具有 pushpop 等方法。

除了赋予 Interface 之外,还有 objectObject{} 这三个类型。

被原型链折磨过的人可能知道原型链的顶端是 Object 的原型,也就意味着所有原始类型和对象类型都会指向 Object,在 TS 中就包含了所有的类型:

const tmp1: Object = undefined;
const tmp2: Object = null;
const tmp3: Object = void;
const tmp4: Object = 'lanshan';
const tmp5: Object = 599;
const tmp6: Object = { name: 'lanshan' };
const tmp7: Object = () => {};
const tmp8: Object = [];

除了 Object 之外,还有 BooleanNumberString 等几个装箱类型,其同样包含了一些超出预期的类型,如 String 包含对应的拆箱类型 string,以及 undefinednullvoid 等,不包括其他装箱类型的拆箱类型。

在任何情况下都不应该使用这些装箱类型。

object 的引入就是为了解决对 Object 装箱类型的错误使用,它代表所有非原始类型的类型,即数组、对象、函数类型等:

const tmp22: object = { name: 'lanshan' };
const tmp23: object = () => {};
const tmp24: object = [];

当你不确定某个变量的具体类型,但能确定它不是原始类型,可以使用 object。但更推荐进一步区分,也就是使用 Record<string, unknown>Record<string, any> 表示对象,unknown[]any[] 表示数组,(...args: any[]) => any 表示函数这样。

最后一个是 {}{} 意味着任何非 null/undefined 的值,使用它和使用 any 同样恶劣。

字面量类型、联合类型

我们先来看一下如下的代码:

interface IRes {
  code: number;
  status: string;
  data: any;
}

在大多数情况下 ,这里的 codestatus 会来自于一组确定值的集合,比如 code 可能是 1000/1001/1002 中的一个,而 status 可能是 success/failure 中的一个,而上面的类型只给出了一个宽泛的范围,我们不能获取精准的提示的同时,也失去了 TS 类型即文档的功能。

这个时候我们就可以使用字面量类型和联合类型:

interface Res {
  code: 10000 | 10001 | 50000;
  status: "success" | "failure";
  data: any;
}

这个时候我们就可以获得精准的类型推导了。

字面量类型:上面的 "success" 按理说应该是一个值,但是它同时也可以作为类型使用,也就是字面量类型,它代表着比原始类型更精准的类型,同时也是原始类型的子类型。

const str: "lanshan" = "lanshan";
const num: 599 = 599;
const bool: true = true;

const str1: "lanshan" = "lanshan";
const str2: string = "lanshan";

单独使用字面量类型比较少见,通常情况下是和联合类型一起使用,用来表示一组字面量类型。

联合类型:联合类型代表了一组类型的可用集合,只要最终赋值的类型属于联合类型的成员之一,就认为是符合这个联合类型的,联合类型之间的成员通过 | 来连接。

联合类型还有一种使用场景就是通过多个对象的联合来实现手动的互斥属性:

interface Tmp {
  user:
     {
        vip: true;
        expires: string;
      }
    | {
        vip: false;
        promotion: string;
      };
}

如这段代码,user 中使用联合类型,如果 viptrue 的话,那么接下来的类型就会收窄到 vip 允许的类型。

枚举

enum PageUrl {
  Home_Page_Url = "url1",
  Setting_Page_Url = "url2",
  Share_Page_Url = "url3",
}

如果说元组是特殊的数组,那么也可以将枚举理解为特殊的对象,枚举内的这些常量被真正约束在一个命名空间下。

如果你没有声明枚举的值,它会默认使用数字枚举,并从 0 开始,以 1 递增。

enum Items {
  Foo, // 0
  Bar, // 1
  Baz // 2
}

在这个例子中,Items.FooItems.BarItems.Baz 的值依次是 0,1,2 。

如果你只为一个成员指定了枚举值,之前未赋值成员仍然会使用从 0 递增的方式,之后的成员则会开始从枚举值递增。

enum Items {
  // 0 
  Foo,
  Bar = 599,
  // 600
  Baz
}

枚举类型也支持延迟求值:

const returnNum = () => 100 + 499;

enum Items {
  Foo = returnNum(),
  Bar = 599,
  Baz
}

枚举和对象还有一个重要的区别:对象是单向映射的,而枚举是双向映射的。枚举既可以从枚举对象映射到枚举值,也可以从枚举值映射到枚举成员。

enum Items {
  Foo,
  Bar,
  Baz
}

const fooValue = Items.Foo; // 0
const fooKey = Items[0]; // "Foo"

// 以上枚举会被编译成如下 JS
"use strict";
var Items;
(function (Items) {
    Items[Items["Foo"] = 0] = "Foo";
    Items[Items["Bar"] = 1] = "Bar";
    Items[Items["Baz"] = 2] = "Baz";
})(Items || (Items = {}));

obj[k] = v 的返回值是 v,因此这里的 obj[obj[k] = v] = k 本质上就是进行了 obj[k] = vobj[v] = k 这样的两次赋值。

只有枚举值为数字的枚举成员才能进行这样的双向枚举,字符串枚举成员仍然只会单向映射。

函数

函数类型就是描述函数入参和函数返回值的类型,下面是一个简单的例子:

function foo1(name: string): number {
  return name.length;
}

JS 中,我们称上面的写法为函数声明,除了函数声明之外,我们还可以通过函数表达式来声明一个函数:

const foo2 = function (name: string): number {
  return name.length
}
const foo3: (name: string) => number = function (name) {
  return name.length
}

这里的 foo3 中的 (name: string) => numberTS 中的函数类型签名,箭头前表示入参的类型,箭头后表示返回值的类型。

而实际的箭头函数的类型标注是这样的:

const foo4 = (name: string): number => {
  return name.length
}
const foo5: (name: string) => number = (name) => {
  return name.length
}

在函数中我们也可以指定可选参数和默认参数:

// 在函数逻辑中注入可选参数默认值
function foo6(name: string, age?: number): number {
  const inputAge = age || 18; // 或使用 age ?? 18
  return name.length + inputAge
}
// 直接为可选参数声明默认值
function foo7(name: string, age: number = 18): number {
  const inputAge = age;
  return name.length + inputAge
}

也可以在函数中使用 rest 参数:

// 数组接收
function foo8(arg1: string, ...rest: any[]) { }
// 元组接收
function foo9(arg1: string, ...rest: [number, boolean]) { }

下面将介绍一下函数重载的概念。

在复杂逻辑下,函数可以有多组入参类型和返回值类型:

function func(foo: number, bar?: boolean): string | number {
  if (bar) {
    return String(foo);
  } else {
    return foo * 599;
  }
}

该函数的作用是当 bartrue,返回值为 string 类型,否则为 number 类型。对于这种函数类型,我们不能直观感受到,只能知道这个函数返回值可能是 string 或者 number

这个时候就需要用到函数重载的概念。将上面例子用函数重载重写了之后为:

function func(foo: number, bar: true): string;
function func(foo: number, bar?: false): number;
function func(foo: number, bar?: boolean): string | number {
  if (bar) {
    return String(foo);
  } else {
    return foo * 599;
  }
}

const res1 = func(599); // number
const res2 = func(599, true); // string
const res3 = func(599, false); // number

any、unknown、never 与类型断言

有些时候 TS 并不需要特别精准的类型控制,比如 conosle.log 方法能够接收任意类型的参数,不管是数组、字符串、对象或者是其他的,统统来着不拒,那难道我们需要把所有的类型全部通过联合类型串联起来?

TS 提供了一个 any 内置类型,来表示任意类型。

log(message?: any, ...optionalParams: any[]): void

在这里,一个被标记为 any 类型的参数可以接受任意类型的值。除了 messageany 以外,optionalParams 作为一个 rest 参数,也使用 any[] 进行了标记,这就意味着你可以使用任意类型的任意数量类型来调用这个方法。

你可以在 any 类型变量上任意地进行操作,包括赋值、访问、方法调用等等,此时可以认为类型推导与检查是被完全禁用的,它能兼容所有类型,也能够被所有类型兼容

因为其过于自由,并不推荐使用 any

如果你是想表达一个未知类型,更合理的方式是使用 unknown

unknown 类型和 any 类型有些类似,一个 unknown 类型的变量可以再次赋值为任意其它类型,但只能赋值给 anyunknown 类型的变量。

let unknownVar: unknown = "lanshan";
unknownVar = false;
const val1: string = unknownVar; // Error
const val2: any = unknownVar;

要对 unknown 类型进行属性访问,需要进行类型断言,类型断言的作用是虽然这是一个未知的类型,但我跟你保证它在这里就是这个类型。

let obj: unknown = {
	sayName: () => {
		console.log("lanshan");
  }
}
obj.sayName() // Error
(obj as { sayName: () => {} }).sayName() // 使用类型断言

const str: string = "lanshan";
(str as any).func().foo().prop;

function foo(union: string | number) {
  if ((union as string).includes("lanshan")) { }
  if ((union as number).toFixed() === '599') { }
}

除了类型断言之外,还有非空断言:

非空断言其实是类型断言的简化,它使用 ! 语法,即 obj!.func()!.prop 的形式标记前面的一个声明一定是非空的(实际上就是剔除了 nullundefined 类型),比如这个例子:

declare const foo: {
  func?: () => ({
    prop?: number | null;
  })
};

foo.func().prop.toFixed();

funcfoo 中不一定存在,propfunc 调用结果中不一定存在,在我们确定存在的时候,可以使用非空断言来避免 TS 报错。

了解了 anyunknown,而 never 类型,其表示根本不存在的类型。

// 这里的 declare 就相当于是声明一个类型,但是不像声明变量那样会占用内存
declare const strOrNumOrBool: string | number | boolean;

if (typeof strOrNumOrBool === "string") {
  console.log("str!");
} else if (typeof strOrNumOrBool === "number") {
  console.log("num!");
} else if (typeof strOrNumOrBool === "boolean") {
  console.log("bool!");
} else {
  throw new Error(`Unknown input type: ${strOrNumOrBool}`); // never 类型的 strOrNumOrBool
}

如上面的例子,在之前的判断中,我们将所有类型的情况都考虑到了,如果不满足所有条件的话,就会进入到最后的 else,也就是根本不会存在的类型中,这里就是 never 类型。

五、类型工具

如果说 TS 中的内置类型就像是最基础的积木,仅仅是拥有积木是不够的,还需要一些类型工具来帮助我们操作这些类型。

按照使用目的来分类可以分为类型创建类型安全保护两类。

类型别名

类型别名 type 并不复杂,它可以用来定义一个类型的别名:

type StatusCode = 200 | 301 | 400 | 500 | 502;
type Handler = (name: string) => void;
type Objtype = {
  name: string;
  age: number;
}
// 类型别名也可以引用其他的类型别名,组成更复杂的类型结构
type Name = string;
type Age = number;

type Person = {
 name: Name;
 age: Age;
};

类型别名结合泛型可以帮助我们更好地使用其作为类型工具的作用,对于泛型这里我们只了解其和类型别名相关的使用,可以简单理解为就像函数接收参数一样,该类型也会接收一个类型参数。

type Factory<T> = T | number | string;
const foo: Factory<boolean> = true;
type FactoryWithBool = Factory<boolean>;

对于这里的 T 的命名,并不是固定的,写成 T 只是我们的约定俗成。

可以通过类型别名结合泛型来声明一些有意思的类型工具:

type MaybeNull<T> = T | null; // 可能为 null
type MaybeArray<T> = T | T[]; // 可能为数组

类型别名与接口(interface)有一定的相似性,但它们之间存在一些差异。类型别名更适合表示联合类型、交叉类型、元组等,而接口更适合表示对象的结构。此外,接口可以被合并(当多个接口具有相同的名称时),而类型别名不具备这个特性,比如:

interface Person {
  name: string;
  age: number;
}
interface Person {
  address: string;
}
const person: Person = {
  name: 'lanshan',
  age: 30,
  address: '123 Main St.',
};

类型别名不具备这个特性,如果你定义了两个具有相同名称的类型别名,它们不会自动合并,而是会产生一个编译时错误。

联合类型与交叉类型

TypeScript 中,联合类型 | 和交叉类型 & 是两个重要的概念。

之前我们了解字面量类型的时候已经了解了联合类型 | 了,还有一个和联合类型相似的孪生兄弟:交叉类型 &

对于联合类型,你只需要满足其中一个类型即可,而对于交叉类型,需要符合里面所有的类型,通常和对象类型一起使用:

interface NameStruct {
  name: string;
}
interface AgeStruct {
  age: number;
}
type ProfileStruct = NameStruct & AgeStruct;
const profile: ProfileStruct = {
  name: "lanshan",
  age: 18
}

如果我们将两个原始类型交叉:

type StrAndNum = string & number; // never

会返回类型 never,因为根本不存在既是 string 又是 number 的类型,而 never 又正好描述了根本不存在的类型。

如果是两个联合类型组成的交叉类型:

type UnionIntersection1 = (1 | 2 | 3) & (1 | 2); // 1 | 2
type UnionIntersection2 = (string | number | symbol) & string; // string

我们只需要实现两边联合类型的交集就行了。

索引类型

索引类型包含索引签名类型索引类型查询索引类型访问,其都是通过索引的形式来进行类型操作。

索引签名类型

索引类型签名主要是在接口或对象类型别名中,快速声明一个键值类型一致的类型结构:

interface AllStringTypes {
  [key: string]: string;
}

type AllStringTypes = {
  [key: string]: string;
}

interface Person {
  name: string;
  age: number;
  [key: string]: string | number;
}

索引类型查询

索引类型查询也就是 keyof 操作符,其可以将对象中所有键转换为对应的字面量类型,再组成联合类型。

interface Foo {
  lanshan: 1,
  599: 2
}

type FooKeys = keyof Foo; // "lanshan" | 599

可以通过以下伪代码来进行理解:

type FooKeys = Object.keys(Foo).join(" | ");

索引类型访问

JS 中我们可以通过 obj[expression] 的方式来访问一个对象的属性,而在 TS 中,我们也可以通过 type[expression] 的方式来访问一个键所对应的类型。

interface Foo {
  propA: number;
  propB: boolean;
}

type PropAType = Foo['propA']; // number
type PropBType = Foo['propB']; // boolean

将索引类型查询和索引类型访问结合在一起,可以一次性获取这个对象所有的键对应的类型的联合类型:

interface Foo {
  propA: number;
  propB: boolean;
  propC: string;
}

type PropTypeUnion = Foo[keyof Foo]; // string | number | boolean

映射类型

这类类型工具可以将一个类型的属性映射到另一个类型,我们直接来看示例:

type Stringify<T> = {
  [K in keyof T]: string;
};

假设这个工具类型会接受一个对象类型( T 为一个对象类型,也就是我们需要映射的对象),使用 keyof 获得这个对象类型的键名组成字面量联合类型,然后通过映射(即这里的 in 关键字,用来遍历联合类型)将这个联合类型的每一个成员映射出来,并将其键值类型设置为 string

interface Foo {
  prop1: string;
  prop2: number;
  prop3: boolean;
  prop4: () => void;
}

type StringifiedFoo = Stringify<Foo>;

// 等价于
interface StringifiedFoo {
  prop1: string;
  prop2: string;
  prop3: string;
  prop4: string;
}

理解了上面代码之后,我们可以实现一个 Clone 的类型工具:

type Clone<T> = {
  [K in keyof T]: T[K];
};

类型查询操作符

TS 提供了 typeof 操作符来返回后面参数的类型。

类型守卫

is 关键字

TS 中提供了类型推导能力,其随着你的代码逻辑不断尝试收窄类型,这一能力称之为类型控制流。

可以想象成有一条河流,其从上到下流过程序,随着代码分支出一条条支流,只有特定的类型才能进入对应的支流。

// 这里的 declare 就相当于是声明一个类型,但是不像声明变量那样会占用内存
declare const strOrNumOrBool: string | number | boolean;

if (typeof strOrNumOrBool === "string") {
  // 一定是字符串!
  strOrNumOrBool.charAt(1);
} else if (typeof strOrNumOrBool === "number") {
  // 一定是数字!
  strOrNumOrBool.toFixed();
} else if (typeof strOrNumOrBool === "boolean") {
  // 一定是布尔值!
  strOrNumOrBool === true;
} else {
  // 要是走到这里就说明有问题!不存在的类型用 never
  const _exhaustiveCheck: never = strOrNumOrBool;
  throw new Error(`Unknown input type: ${_exhaustiveCheck}`);
}

我们也可以将类型判断通过 is 关键字抽离到外面:

function isString(input: unknown): input is string {
  return typeof input === "string";
}

function foo(input: string | number) {
  if (isString(input)) {
    // ...
  }
}

下面是一个比较常用的来判断是否属于 Falsy 类型的工具:

export type Falsy = false | "" | 0 | null | undefined;

export const isFalsy = (val: unknown): val is Falsy => !val;

in 关键字

我们可以通过 in 操作符来判断类型是否存在在对象类型中:

interface Foo {
  foo: string;
  fooOnly: boolean;
  shared: number;
}

interface Bar {
  bar: string;
  barOnly: boolean;
  shared: number;
}

function handle(input: Foo | Bar) {
  if ('foo' in input) {
    input.fooOnly;
  } else {
    input.barOnly;
  }
}

六、泛型

在类型工具学习中,已经接触过了类型别名中的泛型,比如类型别名如果声明了泛型坑位,那其实就等价于一个接受参数的函数:

type Factory<T> = T | number | string;

上面这个类型别名的本质就是一个函数,T 就是它的变量,返回值则是一个包含 T 的联合类型。

类型别名中的泛型大多是用来进行工具类型封装,比如映射类型中的工具类型:

type Stringify<T> = {
  [K in keyof T]: string;
};
type Clone<T> = {
  [K in keyof T]: T[K];
};

还有一种好玩的类型工具,其将所有属性都改为可选:

type Partial<T> = {
    [P in keyof T]?: T[P];
};

泛型约束(条件类型)

泛型约束也叫条件类型,在函数中,如果我们需要判断入参的某个值是否符合某些条件,如果不符合就不执行后面的逻辑,我们只能在函数内部通过 return 进行处理。

而在泛型中,我们可以通过 extends 来对泛型进行约束。

type IsEqual<T> = T extends true ? 1 : 2;

type A = IsEqual<true>; // 1
type B = IsEqual<false>; // 2
type C = IsEqual<'lanshan'>; // 2

type ResStatus<ResCode extends number> = ResCode extends 10000 | 10001 | 10002
  ? 'success'
  : 'failure';

在条件类型参与的情况下,通常泛型会被作为条件类型中的判断条件(T extends Condition,或者 Type extends T)以及返回值(即 : 两端的值),这也是我们筛选类型需要依赖的能力之一。

泛型默认值

像函数可以声明一个参数的默认值一样,泛型同样有着默认值的设定:

type Factory<T = boolean> = T | number | string;

多泛型

我们可以同时传入多个泛型参数:

type Conditional<Type, Condition, TruthyResult, FalsyResult> =
  Type extends Condition ? TruthyResult : FalsyResult;

内置方法中的泛型

TS 中为许多内置对象都预留了泛型坑位,比如在 Promise 中:

function p() {
  return new Promise<boolean>((resolve, reject) => {
    resolve(true);
  });
}

React 中:

const [state, setState] = useState<number[]>([]);
// 不传入默认值,则类型为 number[] | undefined
const [state, setState] = useState<number[]>();

// 体现在 ref.current 上
const ref = useRef<number>();

const context =  createContext<ContextType>({});

泛型结合类型工具

TS 中有很多内置的类型工具,可以使我们更灵活地处理类型,提高类型代码的可读性,这些都是需要结合泛型一起来使用的:

  1. Partial<T>:将类型 T 的所有属性设置为可选。
  2. Required<T>:将类型 T 的所有属性设置为必需。
  3. Readonly<T>:将类型 T 的所有属性设置为只读。
  4. Pick<T, K>:从类型 T 中选择一组属性 K
  5. Omit<T, K>:从类型 T 中排除一组属性 K
  6. Record<K, T>:创建一个类型,其属性键为 K,属性值为 T
  7. Extract<T, U>:从类型 T 中提取可以分配给类型 U 的类型。
  8. Exclude<T, U>:从类型 T 中排除可以分配给类型 U 的类型。
  9. NonNullable<T>:从类型 T 中排除 nullundefined
  10. ReturnType<T>:获取函数类型 T 的返回类型。
  11. InstanceType<T>:获取构造函数类型 T 的实例类型。

这里有一个简单的示例,展示了如何使用 PartialPick类型工具:

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

// 使用 Partial 将所有属性设置为可选
type PartialPerson = Partial<Person>;

// 使用 Pick 仅选择 'name' 和 'age' 属性
type NameAndAge = Pick<Person, 'name' | 'age'>;

可以根据需要组合和使用这些类型工具,以便更好地满足项目需求。

七、TS 在 React 中的应用

参考链接:如何优雅地在 React 中使用TypeScript,看这一篇就够了!

八、类型体操

类型编程被戏称为类型体操,也就是我们可以对传入的类型参数(泛型)做各种逻辑运算,产生新的类型,这就是类型编程。

infer 类型

首先我们讲一个之前没有提到过的类型:infer,用于在条件类型中推断类型变量。

具体来说,infer 关键字可以用于从一个类型中提取出另一个类型,并将其赋值给一个类型变量。这个类型变量可以条件类型中使用,从而实现类型的推断。

比如我们有一个类型 ReturnType,它可以获取一个函数类型的返回值类型:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

在上面的例子中,我们使用了 infer 关键字来推断函数类型的返回值类型。具体来说,我们使用了条件类型 T extends (...args: any[]) => infer R,它表示如果类型 T 是一个函数类型,则将其返回值类型赋值给类型变量 R

function add(a: number, b: number): number {
  return a + b;
}

type AddReturnType = ReturnType<typeof add>; // number

我们再看一个例子理解一下:

type First<Tuple extends unknown[]> = Tuple extends [infer T,...infer R] ? T : never;

type res = First<[1,2,3]>; // 1

对于类型体操过于复杂,这里只介绍两种比较简单常用的套路。

模式匹配

我们知道字符串可以和正则表达式做模式匹配,找到匹配的部分并返回。

TS 的类型同样也可以做模式匹配。

比如我们想提取一个如下的 Promise 类型的 value 的类型:

type GetValueType<T> = T extends Promise<infer Value> ? Value : never;

type p = Promise<'lanshan'>;
type pRes = GetValueYype<p>; // 'lanshan'

Typescript 类型的模式匹配是通过 extends 对类型参数做匹配,结果保存到通过 infer 声明的局部类型变量里,如果匹配就能从该局部变量里拿到提取出的类型。

数组中的模式匹配

如果数组想提取第一个元素或者最后一个元素的类型该怎么做?

// 提取第一个
type GetFirst<Arr extends unknown[]> = 
    Arr extends [infer First, ...unknown[]] ? First : never;

// 提取最后一个
type GetLast<Arr extends unknown[]> = 
    Arr extends [...unknown[], infer Last] ? Last : never;

或者我们想去掉数组的第一个元素或最后一个元素:

// 去掉最后一个元素
type PopArr<Arr extends unknown[]> = 
    Arr extends [] ? [] 
        : Arr extends [...infer Rest, unknown] ? Rest : never;

// 去掉第一个元素
type ShiftArr<Arr extends unknown[]> = 
    Arr extends [] ? [] 
        : Arr extends [unknown, ...infer Rest] ? Rest : never;

字符串中的模式匹配

判断字符串是否以某个前缀开头:

type StartsWith<Str extends string, Prefix extends string> = 
    Str extends `${Prefix}${string}` ? true : false;

替换字符串的某部分:

type ReplaceStr<
    Str extends string,
    From extends string,
    To extends string
> = Str extends `${infer Prefix}${From}${infer Suffix}` 
        ? `${Prefix}${To}${Suffix}` : Str;

函数中的模式匹配

获取函数参数:

type GetParameters<Func extends Function> = 
    Func extends (...args: infer Args) => unknown ? Args : never;

获取返回值参数:

type GetReturnType<Func extends Function> = 
    Func extends (...args: any[]) => infer ReturnType 
        ? ReturnType : never;

递归复用

TS 中对于两种情况我们需要想到递归,第一种是当数组、字符串长度不确定时,第二种是当对象层数不确定时。

我们先来看一下数组、字符串使用递归的情况。

首先是反转数组:

// 需要我们把下面数组
type arr = [1,2,3,4,5];
// 转化为如下的数组
type arr = [5,4,3,2,1];

如果不用递归我们可能会写成这样:

type ReverseArr<Arr extends unknown[]> = 
    Arr extends [infer One, infer Two, infer Three, infer Four, infer Five]
        ? [Five, Four, Three, Two, One]
        : never;

但是如果数组的长度不确定,上面的方法就失效了,所以我们可以想到每次只处理一个类型,剩下的递归去做,直到满足结束条件:

type ReverseArr<Arr extends unknown[]> = 
    Arr extends [infer First, ...infer Rest] 
        ? [...ReverseArr<Rest>, First] 
        : Arr;

这段代码的逻辑就是每次只提取第一个元素,并放到最后,剩余的元素作为数组又进行之前的操作,结束的情况就是最后数组只剩下一个元素,不满足模式匹配的条件,就会返回 Arr

如果需要实现在数组中查找是否存在某个元素:

// 判断两个元素是否相等
type IsEqual<A, B> = (A extends B ? true : false) & (B extends A ? true : false);

type Includes<Arr extends unknown[], FindItem> = 
    Arr extends [infer First, ...infer Rest]
        ? IsEqual<First, FindItem> extends true
            ? true
            : Includes<Rest, FindItem>
        : false;

从第一个元素开始和查找的元素进行判断,当相同时返回 true,否则用剩下的元素作为数组继续判断。

接下来我们来看字符串,之前我们写了字符串的 replace,但是该类型只能替换第一个找到的部分,不能将该字符串所有的匹配部分给替换掉,我们可以通过递归来改写:

type ReplaceAll<
    Str extends string, 
    From extends string, 
    To extends string
> = Str extends `${infer Left}${From}${infer Right}`
        ? `${Left}${To}${ReplaceAll<Right, From, To>}`
        : Str;

结束条件是不再满足模式匹配,也就是没有要替换的元素,这时就直接返回字符串 Str

对于对象来说,我们通过对象类型深拷贝来体现。

如果我们想要给一个对象类型所有属性添加上只读特性,可以这样操作:

type ToReadonly<T> =  {
    readonly [Key in keyof T]: T[Key];
}

但这样只能添加到第一层,如果我们传入的对象类型是这样的:

type obj = {
    a: {
        b: {
            c: {
                f: () => 'lan',
                d: {
                    e: {
                        shan: string
                    }
                }
            }
        }
    }
}

那我们就需要用到递归:

type DeepReadonly<Obj extends Record<string, any>> = {
    readonly [Key in keyof Obj]:
        Obj[Key] extends object
            ? Obj[Key] extends Function
                ? Obj[Key] 
                : DeepReadonly<Obj[Key]>
            : Obj[Key]
}