TypeScript - 类型体操

664 阅读12分钟

类型体操

TypeScript 介绍

TypeScript 由微软开发的自由和开源的编程语言。

TypeScript 是 JavaScript 的一个超集,TypeScript 本身是没有改变JavaScript 语法的,而只是 给 JavaScript 增加了一套静态类型系统,而通过 TS Compiler 编译为 JS,编译的过程做类型检查。

1280X1280.png

什么是类型体操

除了TypeScript 以外,一些编程语言本身就自带类型系统,天然支持类型定义和检查。例如 Java、C++ 等。 但是只有 TypeScript 的类型编程被叫做类型体操,为什么其他语言没有呢?

类型系统从简单到复杂可以分为三类:简单类型系统、支持泛型的类型系统、支持类型编程的类型系统

简单类型系统

变量、函数、类等都可以声明类型,编译器会基于声明的类型做类型检查,类型不匹配时会报错。

const a: number = 1
const add = (a:number,b:number):number =>{
    return  a+b
}

支持泛型的类型系统

泛型的英文是 Generic Type,通用的类型,可以代表任何一种类型,也叫做类型参数。

type AddType<T> = (a: T) => T
type fun = AddType<number>

声明时把会变化的类型声明成泛型(也就是类型参数),在调用的时候再确定类型。

虽然增加一点灵活性,但是 对于JavaScript 来说还不够。例如下面这种情况:

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

存在问题:即使拿到了T,但是也无法获取它的属性和属性值。

支持类型编程的类型系统

传入的类型参数(泛型)做各种逻辑运算,产生新的类型,这就是类型编程。

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

TypeScript 不仅仅只是为JavaScript 增了一套类型系统而已,而对于它自身而言,它属于是图灵完备 的静态编程语言

结论:类型体操就是充分利用 TS 提供的一系列计算机指令,来进行类型编程

所以对待TS,不应该只把他当作一门类型定义系统来看待,而应该把它当作一门完整的编程语言来学习。

  • 类型的声明定义
  • 条件判定和遍历循环
  • 特殊的语法概念
  • 编程的一些技巧

类型基础

类型定义

  • 基本类型:number、boolean、string、bigint、symbol、undefined、null

  • 复合类型: Class、Array、object、元组(Tuple) 、接口(Interface)、枚举(Enum)

  • 特殊类型: void、never、any、unknown

undefined & null

在 TS 中 两者都是有具体含义的,没有开启 strictNullChecks检查的情况下,会被视作其他类型的子类型。(never 除非)

const tmp1: null = null;
const tmp2: undefined = undefined;

const tmp3: string = null; // 仅在关闭 strictNullChecks 时成立 
const tmp4: string = undefined;

object

表示对象,但在Ts 中存在 object Object{} , 这三者都表示一个空对象。

  • Object 表示包含了所有的类型。
 // 对于 undefined、null、void 0 ,需要关闭 strictNullChecks  const tmp1: Object = undefined;
const tmp2: Object = null;
const tmp3: Object = void 0;
const tmp4: Object = 'hello';
const tmp5: Object = 599;
const tmp6: Object = { name: 'hello' };
const tmp7: Object = () => {};
const tmp8: Object = [];
  • object:表示的就是除基本类型以外的类型,例如 数组 、对象、函数和类。
const tmp1: object = 'hello';  // 不成立,  const tmp2: object = 599; // 不成立,
const tmp3: object = () => {};
const tmp4: object = [];
const tmp5: object = { name: 'hello' };
class a {}
const tmp6:object = a
  • {} 可以认为就是个对象字面量类型,但它的内部无属性定义。 类似于 new Object,使用它声明的变量是无法进行任何赋值操作的。
const tmp1: {} = { name: 'hello' };
tmp1.age = 18; // X 类型“{}”上不存在属性“age”。 

总结:

  • 在任何时候都应该避免使用 Object , {} ,使用它没有任何实际意义。

  • 在不确定变量属于哪种复合类型的时候,可以用object。如果知道是对象结构 ,就建议使用Record<string,any> 来声明类型。

元组(Tuple)

元组表示的元素个数和类型固定的数组类型

type Tuple = [number, string]; // 元组类型

type arry  = Array<string|number> // 数组类型

特殊类型:

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

类型工具

类型工具主要分为两类:创建类型类型保护

创建类型:联合交叉、索引类型、映射类型、类型查询

类型保护:条件类型

联合交叉

交叉类型使用 &, 按位与运算符。

联合类型使用 |,按位或运算符。

交叉类型

对于声明的对象类型,使用交叉类型,最终的效果是合并

interface IName {
  name:string
}
interface IAge {
  age:number
}
// interface 定义
interface Lisi extends IName,IAge {
    isMan:  boolean
}
// type 定义
type Lisi  = IName & IAge & {  isMan:  boolean }

对于不同的基础类型进行交叉,就会得到never,表示不存在的类型

type List = string & number // never

联合类型

联合类型和交叉类型的区别就是,联合类型只需要符合成员之一即可,而交叉类型需要严格符合每一位成员。

type a = (1 | 2 | 3) & (1 | 2); // 1 | 2  
type b = (string | number | symbol) & string; // string

索引类型

索引类型主要包含三个部分::索引签名索引查询 索引访问

索引签名

在接口或类型别名中,快速声明一个键值类型一致的类型结构。

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

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

索引签名和具体的键值共存

interface A {
  age:number;
  [key: string]: string;
}

索引查询

使用 keyof 操作符,将对象中的所有键转换为对应字面量类型。

注意:数字的键名转换为字面量的时候,不会变成字符串,仍然保持数字

interface A {
  name:string;
  2022:boolean;
  "2023":number;
}
type B = keyof A // name |2022| ‘2023’
const c:B = 2023 // Type '2023' is not assignable to type 'keyof A'

keyof 不仅适用于对象类型获取它的键,也适用基础类型,获取原型上的键名。

type str = keyof string // number|"toString" | typeof Symbol.iterator ...
type strPro = string['toString']// () => string

索引访问

在JS 中通过 object[expression] 来动态访问属性。 而在TS 中也基本类似的方式,把表达式换成类型

interface A {
  [key: string]: number;
}
type AType = A[string]

通过字面量类型来进行索引类型访问

interface A {   
   name:string;   
   "2023":number;  
   2022:string 
}
type AType = A[2023] 
type BType = A[2022] 
type CType = A['name'] 

搭配索引查询keyof来访问

interface A {   
   name:string;   
   "2023":number;   
   2022:string
} 
type AType = A[keyof A] // string|number

映射类型

走向类型编程的第一步

映射类型的主要作用即是基于键名映射到键值类型。

type MapType<T> = {   
    [Key in keyof T]: T[Key] 
}
  • keyof T 是查询索引类型中所有的索引,叫做索引查询
  • T[Key] 是取索引类型某个索引的值,叫做索引访问
  • in 是用于遍历联合类型的运算符。

总结:映射类型就是把一个集合重新映射生成另一个集合。

使用 as 运算符,实现重映射

type MapType<T> = {   
  [Key in keyof T as `${Key & string}${Key & string}`]: [T[Key],T[Key]] 
}

Key&string

索引类型要求的键是 string、number、symbol , 而keyof T 查询出来索引可能是 string | number | bigint | boolean | null | undefined 的联合类型,所以我们需要使用交叉类型 & ,取 string 作为新的索引。

3edbc8a9-6f53-405b-9d89-b02181ca410e.png

类型查询

类型查询使用的操作符是 typeof ,这个在js中是最常见的,主要用来检查变量类型。而在Ts中是可以通过它来对当前的变量,表达式进行类型的推导,而且推导是最窄的推导粒度(字面量类型级别)。

const str = "hello";
const obj = { name: "lisi" };
const nullVar = null;
const undefinedVar = undefined
const func = (input: string) => {
  return input.length > 10;
}

type Str = typeof str; // "hello"
type Obj = typeof obj; // { name: string }
type Null = typeof nullVar; // null
type Undefined = typeof undefined; // undefined
type Func = typeof func; // (input: string) => boolean

在实际开发中 typeof 同时存在于逻辑层和类型层的,所以为了隔离类型层和逻辑层。类型层中的typeof 后面是不能跟表达式的

1.png

条件类型

条件类型用法上看起来有点像 JavaScript 中的三元运算:

条件 ? true 表达式 : false 表达式

对应的TS的写法:

SomeType extends OtherType ? TrueType : FalseType

两者重要的区别 :extends 是根据类型的兼容性来判定是否成立,而不是根据类型是否全等来判定。

最直观的一个兼容性判定案例,父子继承

class Animal {
    doAnimalThing(): void {
        console.log("I am a Animal!")
    }
}
class Dog extends Animal {
    doDogThing(): void {
        console.log("I am a Dog!")
    }
}
type Example1 = Dog extends Animal ? number : string; // number

在TS的类型系统中,兼容性的判定可以根据类型层级链来判定。

2.png

type Result = never extends 1
  ?  1 extends  1 | 2 | 3
  ? 'name' | '1' extends string
  ? string extends String
  ? String extends Object
  ? Object extends any
  ? any extends unknown
  ? unknown extends any
  ? 8
  : 7
  : 6
  : 5
  : 4
  : 3
  : 2
  : 1
  : 0
  • 配合泛型实现类型约束

在大部分的实际场景中,条件类型主要配合泛型来实现条件约束,

type MessageOf<T> = T["message"];
// Type '"message"' cannot be used to index type 'T'.

type MessageOf<T extends { message: unknown }> = T["message"];
  • 配合 Infer 关键字和模式匹配来实现类型推导和提取

Infer 关键字在条件类型中用来提取一部分未知类型(unknow

举例:反转对象键名与键值

type ReverseKeyValue<T extends Record<string, string>> = 
    T extends Record<infer K, infer V> ? 
        Record<V & string, K> : never

最终再结合模版字符串,实现类型的推导和提取

举例: 实现字符串提取替换

type ReplaceStr<
    Str extends string,
    From extends string,
    To extends string
> = Str extends `${infer Prefix}${From}${infer Suffix}` 
        ? `${Prefix}${To}${Suffix}` : Str;
        
type str = ReplaceStr < 'hello world' , 'world' , 'ts' > // "hello ts" 
  • 分布式条件类型

检查类型为裸类型参数的条件类型称为分布式条件类型。在实例化过程中,分配条件类型会自动分布在联合类型上。 (Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiating)

总结:分布式条件类型属于 TS 中的一项特殊能力,只要满足一定条件就能触发。 满足条件如下:

  • 类型参数需要是联合类型
  • 是否通过泛型的方式传入。
  • 泛型的参数是否为裸类型,就是不能被数组包裹。

// 是否通过泛型的方式传入
type Extract<T, U> = T extends U ? T : never;
type _Extracte1 = Extract<'a'|'b'|'c', 'a'|'b'>;// 'a'|'b'
type _Extracte2 = 'a'|'b'|'c' extends 'a'|'b' ? 'a'|'b'|'c' : never; // never

// 是否为裸类型参数
type Naked<T> = T extends boolean ? "Y" : "N";
type Wrapped<T> = [T] extends [boolean] ? "Y" : "N";
type Result1 = Naked<number | boolean>;// "N" | "Y"
type Result2 = Wrapped<number | boolean>; // "N"

类型编程

类型编程本质上就是熟练运用上面这些类型工具,然后再结合一些编程的套路和技巧,最终实现对类型编程。

模式匹配

模式匹配主要就是结合条件类型 extends 对类型参数做匹配,再结合infer 关键词,将局部的类型参数推导出来,如果匹配成功,就能将该类型参数提取出来。

举例:

提取数组首位元素


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

type result = ArrayFirst<[1,2,3]>//1

提取函数的参数和返回值

// 提取返回值
type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any) => infer R ? R : any;

//提取参数
type paramsType<T extends Function> = T extends (...args: infer P) => any ? P : any;

构造转换

类型的三种声明方式:

  • 全局声明:使用类型别名 type
  • 局部声明 : 使用类型推导infer ,提取局部类型
  • 参数声明:使用泛型,类似函数传参,也相当于是局部声明

类型编程中:所有的声明都是不支持修改的,如果需要生成新的类型,就需要重新构造和转换。

举例:

首字母大写

type CapitalizeStr<Str extends string> = 
    Str extends `${infer First}${infer Rest}` 
        ? `${Uppercase<First>}${Rest}` : Str;

总结:当我们需要生成一个新的类型的时候, 首先通过type 声明一个新的类型别名,通过泛型的方式传入已知的类型,使用infer 提取局部的类型,然后进行重新构造和转换,生成新的类型。

递归遍历

类型编程系统中,本身是不支持循环的,所以想实现循环的效果,就需要借助递归的方式。

使用场景: 当涉及到类型的数量不确定的时候,就需要使用递归了。

举例:

数组反转

type ar = [1,2,3,4,5]

// 数量已知情况,结合模式匹配,使用infer,直接构造转换
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;

联合分散

联合类型在类型系统中是比较特殊的,它所具备的特性和实际运用场景也是最多的。

联合类型自身是具备分布式特性,当与条件类型结合使用,才称为分布式条件类型。

举例:

触发分布式特性

type union = 'a'|'b'|'c'
type str = `${union}~~` //  "a~~" | "b~~" | "c~~"
type str = `${[union]}~~` // Type '[union]' is not assignable to type 'string | number | bigint | boolean | null | undefined'

触发条件:必须为裸露类型,也就是不能被数组包裹

联合类型与条件类型结合使用的时候,只有条件extends左边的联合类型才会触发分布式特性

举例:

type Union<A, B = A> = A extends A ? { a: A, b: B} : never;
type Result = Union<'a' | 'b' | 'c'>;

结果:

3.png

总结

TypeScript 目前已逐步成为前端项目工程中不可或缺的一部分。但在实际的业务开发中,对于TyepScript的运用往往都不会很复杂,更多的是如何去定义类型,不会出现太复杂的类型编程场景。但是,充分理解类型系统能帮助我们更好地理解复杂类型编程的底层原理,也能够让我们获得独立解决各种类型问题的能力。