typescript 类型体操入门

322 阅读10分钟

为什么要学习 TS 类型体操?

  1. 可以帮助我们更好地理解和掌握 TypeScript 中的类型系统,从而写出更加健壮和可维护的代码
  2. 掌握 TypeScript 可以提升我们在现代 Web 开发中的竞争力,并为我们在开源社区中贡献代码打下基础

什么是 TS 类型体操?

静态类型编程语言都有自己的类型系统,从简单到复杂可以分为 3 类:

简单类型系统

简单类型系统,就是根据声明的类型做检查。比如说 c 语言:

int add(int a, int b){
    return a + b
}
double add(double a, double b){
    return a + b
}

泛型类型系统

泛型类型系统,它支持类型参数,通过给参数传参,可以动态定义类型,让类型更加灵活。

public class Box<T> {

    private T t;

    public void add(T t) {
        this.t = t;
    }

    public T get() {
        return t;
    }

    public static void main(String[] args) {
        Box<Integer> integerBox = new Box<Integer>();
        Box<String> stringBox = new Box<String>();

        integerBox.add(new Integer(10));
        stringBox.add(new String("菜鸟教程"));

        System.out.printf("整型值为 :%d\n\n", integerBox.get());
        System.out.printf("字符串为 :%s\n", stringBox.get());
    }
}

类型编程系统

在 Java 里面,拿到了对象的类型就能找到它的类,进一步拿到各种信息,所以类型系统支持泛型就足够了。但是 JavaScriprt 很灵活,所以仅仅依靠泛型还远远不够,还需要能针泛型进行运算。

比如说:我们有这样一个函数,传入一个对象和 key 返回该对象的值

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

TS 中的类型和运算系统

TS 类型系统中的类型

TS 的类型系统包含了 JS 的基础类型( number、boolean、string、bigint、symbol、undefined、null**)和复合类型(Object、Array)等等,除此之外还有**元组(Tuple)、接口(Interface)、枚举(Enum)、字面量(literal

元组

元组(Tuple)就是元素个数和类型固定的数组类型,与普通的数组不同,元组的每个位置都有一个明确定义的数据类型

type Tuple = [string,number]
type NumberArray = number[]

type TupleLength = Tuple['length'] // 3
type NumberArrayLength = NumberArray['length'] // number

接口

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

对象:

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

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

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

函数:

interface GetName {
    (person: IPerson): string;
}

const func: GetName = (person: IPerson) => {
    return person.name
}

const n = func(obj)

构造器:

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

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

枚举

枚举(Enum)是一种用于定义命名常量集合的强类型结构。它允许我们为一组值(如颜色、方向、状态等)赋予有意义的名字。可以使得代码更有可读性和可维护性

enum Direction {
    Up = 'Up',
    Down = 'Down',
    Left = 'Left',
    Right = 'Right'
  }

let Right = Direction.Right;

字面量

字面量(literal)是一种表示具体值的语法结构,在代码中用于指定常量的值。字面量可以用于定义各种数据类型

let num: 123 = 123;

// 字符串的两种字面量
let str: 'hello' = 'hello';
let str1: `$${number}` = '$100'

let bool: true = true;

let obj: {a: number, b: string} = {a: 1, b: 'hello'};
let arr: number[] = [1, 2, 3];

其他类型

  • void 类型表示没有返回值。例如,一个没有返回值的函数可以声明其返回类型为 void。
  • never 类型表示永远不会返回,即无值可言。例如,一个总是抛出异常的函数可以声明其返回类型为 never。
  • any 类型表示任意类型,相当于关闭了类型检查器。例如,一个变量声明为 any 类型,则可以赋予任何类型的值。
  • unknown 类型也表示任意类型,但它是一种安全的 any 类型,因为它不允许对其直接进行操作。例如,一个函数的参数声明为 unknown 类型,则必须进行类型检查后才能进行操作。
function log(message: string): void {
  console.log(message);
}

function throwError(message: string): never {
  throw new Error(message);
}

let anyValue: any = 'hello';
let unknownValue: unknown = 'world';

log('Hello, TypeScript!');
throwError('Something wrong happened.');

anyValue = 123;
unknownValue = 456;

let num: number = anyValue;
// 编译错误:Type 'unknown' is not assignable to type 'string'.
// let str: string = unknownValue;

TS 类型系统中的类型装饰

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

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

type tuple = [string, number?];

TS 类型系统中的类型运算

条件 extends ? :

条件类型(Conditional Types)是 TypeScript 中一种高级的类型语法,它可以在类型之间进行复杂的计算和比较,从而实现更灵活的类型操作。

条件类型通常使用条件 extends ? : 格式来定义。它的基本语法如下所示:

T extends U ? X : Y

其中,T 是类型参数,U 是一个类型,X 和 Y 是两个可能的输出类型分支。条件类型的含义是,如果类型 T 可以赋值给类型 U,那么输出为类型 X,否则输出为类型 Y。(T 是否是 U 的子集 ? true : false)

type IsNumberArray<T> = T extends number[] ? true : false

type test1 = IsNumberArray<[1,2,3]>
type test2 = IsNumberArray<['1','2','3']>
/**
 * @example
 * type A1 = 1
 */
type A1 = 'x' extends 'x' ? 1 : 2;

/**
 * @example
 * type A2 = 2
 */
type A2 = 'x' | 'y' extends 'x' ? 1 : 2;

/**
 * @example
 * type A3 = 1 | 2
 */
type P<T> = T extends 'x' ? 1 : 2;
type A3 = P<'x' | 'y'>

提问:为什么A2A3的值不一样?

  • 如果用于简单的条件判断,则是直接判断前面的类型是否可分配给后面的类型
  • extends前面的类型是泛型,且泛型传入的是联合类型时,则会依次判断该联合类型的所有子类型是否可分配给 extends 后面的类型(是一个分发的过程)。
type P<T> = [T] extends ['x'] ? 1 : 2;
/**
 * type A4 = 2;
 */
type A4 = P<'x' | 'y'>

条件类型非常灵活,可以用于各种类型操作。例如,可以使用条件类型实现映射类型、递归类型等高级类型。

约束 extends

在 TypeScript 中,可以使用 extends 关键字来进行类型约束。它允许我们将泛型参数限制为某些特定的类型或实现某些特定的接口或类。

当我们使用 extends 关键字约束类型时,必须满足以下两个条件:

  • 约束的类型必须是一个接口、类、类型别名或另一个泛型类型参数;
  • 约束的类型可以是单个类型,也可以是联合类型。
interface HasLength {
  length: number;
}

function printLength<T extends HasLength>(arg: T): void {
  console.log(arg.length);
}

printLength([1, 2, 3]);  // 输出 3
printLength('hello');  // 输出 5
// printLength(123);  // 编译错误:类型“number”上不存在属性“length”

通过使用 extends 关键字进行类型约束,我们可以更加精确地控制泛型的使用范围,避免一些类型错误。此外,在与其他高级类型语法(如条件类型)结合使用时,extends 关键字也非常有用,可以帮助我们编写更加灵活、可复用的类型代码。

推导 infer

在 TypeScript 中,infer 关键字用于提取泛型类型参数中的类型。它通常与条件类型一起使用,以从泛型类型中提取并操作某个子类型。

我们可以在条件类型中使用 infer 关键字来声明一个新的类型变量,以代表泛型参数中对应的类型,并将其赋值给一个新的类型,从而获得该类型变量的类型。

type ElementType<T> = T extends Array<infer U> ? U : never;

type List = [string, number, boolean];
type ListElement = ElementType<List>; // string | number | boolean

通过使用 infer 关键字,我们可以在编写高级类型时更加灵活、精确地控制类型的生成和操作,以满足各种复杂的应用需求。

联合:|

在 TypeScript 中,联合类型使用竖线符号(|)将多个类型组合在一起表示一个值可以是这些类型之一。

function formatInput(input: string | number) {
  if (typeof input === 'string') {
    return input.toUpperCase();
  } else {
    return input.toFixed(2);
  }
}

console.log(formatInput('hello')); // 输出 HELLO
console.log(formatInput(3.1415)); // 输出 3.14

通过使用联合类型,我们可以实现更加灵活的类型匹配和函数逻辑,以满足不同类型的值的处理需求。

交叉:&

在 TypeScript 中,交叉类型使用与符号(&)将多个类型组合在一起表示一个值必须同时满足这些类型的条件。

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

interface Employee {
  company: string;
  salary: number;
}

type EmployeeInfo = Person & Employee; // {name: string;age: number;company: string;salary: number;}

const beal: EmployeeInfo = {
  name: 'Beal',
  age: 23,
  company: 'FUTU',
  salary: 5000
};

console.log(beal); // 输出 { name: 'Beal', age: 23, company: 'FUTU', salary: 5000 }

交叉类型取的多个类型的并集,但是如果相同key但是类型不同,则该keynever**。**

通过使用交叉类型,我们可以实现更加灵活、具有组合性的类型约束和声明,以满足各种类型结构的需要。

索引查询 keyof T

在 TypeScript 中,keyof T 是一个索引类型查询操作符,它用于获取类型 T 中所有公有属性的名称组成的联合类型。这个联合类型可以很方便地用于对对象的属性进行遍历、访问和操作等操作。

interface Person {
  name: string;
  age: number;
  gender: 'male' | 'female';
}

type PersonKeys = keyof Person;

const personKey: PersonKeys = 'name';

console.log(personKey); // 输出 'name'

class Person2{
  private name: string;
  public readonly age: number;
  protected gender: 'male' | 'female';
}
// T2实则被约束为 age
// 而name和home不是公有属性,所以不能被keyof获取到
type PersonKeys2 = keyof Person2 // 'name'

通过使用 keyof T 操作符,我们可以快速访问和操作对象的属性,而不需要手动编写属性名列表,提高了代码的可读性和可维护性。需要注意的是,如果 T 类型不存在任何属性,则 keyof T 操作符将返回 never 类型。

索引访问

在 TypeScript 中,我们可以使用索引访问操作符 [] 来访问一个对象的属性。这种方式通常被称为“索引访问(Index Access)”或“下标访问(Subscript Access)”。通过索引访问操作符,我们可以使用属性名作为字符串或数字来访问对象属性。如果尝试使用一个类型不是字符串或数字的属性名来访问对象,将会出现编译时错误。

interface Person {
    name: string;
    age: number;
    gender: 'male' | 'female';
  }
  
type AgeType = Person['age']; // 类型为 number
type Type = Person[keyof Person]; // 类型为 string | number

** 注意:如果**[]中的 key 有不存在 T 中的,则是 any;因为 ts 也不知道该 key 最终是什么类型,所以是 any;且也会报错。

索引遍历

在 TypeScript 中,我们可以使用 in 操作符,遍历联合类型

const person = {
    name: 'tj',
    age: 11
}

type Person = {
    [P in keyof typeof obj]: number | string
}

索引重映射、映射类型

在 TypeScript 中,映射类型是 TypeScript 中一种常用的类型转换方式之一,可以通过该方式将一个类型中的所有属性名称都重命名为另外一组名称。

interface Phone {
  brand: string;
  model: string;
  price: number;
}

type PhoneKeys = keyof Phone; // "brand" | "model" | "price"

type NewKeys = {
  [K in keyof Phone]: `new_${string & K}`
}[keyof Phone]; // "new_brand" | "new_model" | "new_price"

除了值可以变化,索引也可以做变化,用 as 运算符,叫做重映射

interface Phone {
  brand: string;
  model: string;
  price: number;
}

type NewPhone = {
    [K in keyof Phone as `new_${K}`]: Phone[K];
  }

  /**
   {
    new_brand: string;
    new_model: string;
    new_price: number;
    }
*/

一些小技巧

前面讲的这些语法看起来没有多复杂,但是他们却可以实现很多复杂逻辑,就像 JS 的语法也不复杂,却可以实现很多复杂逻辑一样。因为 JS 本身

匹配提取

我们知道,字符串可以和正则做模式匹配,找到匹配的部分,提取子组,之后可以用 1,1,2 等引用匹配的子组。

'abc'.replace(/a(b)c/,'$0_$1_$2') // a_b_c

Ts 的类型也可以做模式匹配

type GetPromiseValueType<T extends Promise<any>> = T extends Promise<infer Type> ? Type : never

type Test1 = GetPromiseValueType<Promise<number>>

数组类型

type arr = [1,2,3]

用它来匹配一个模式类型,提取第一个元素的类型到通过 infer 声明的局部变量里返回。

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

type Test2 = GetFirst<[1,2,3]> // 1

字符串类型

字符串类型也同样可以做模式匹配,匹配一个模板字符串,把需要提取的部分放到 infer 声明的局部变量里。infer 在这里只会匹配第一个字符

type GetFirstChar<Str extends string> = 
    Str extends `${infer FirstChar}${infer Rest}` ? FirstChar : never;
type Test3 = GetFirstChar<'beal'> // b

判断一个字符串是不是以一个字符串开头的

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


type Test4 = StartsWith<'user_name','user'>

函数类型

函数同样也可以做类型匹配,比如提取参数、返回值的类型。

提取参数

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

type Test5 = GetParameters<(name:string,arg:number)=>number> // [name: string, arg: number]

提取返回值

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

type Test6 = GetReturnType<(name:string,arg:number)=>number> // number

重新构造

类型编程主要的目的就是对类型做各种转换,那么如何对类型做修改呢?

TypeScript 类型系统支持 3 种可以声明任意类型的变量: type、infer、类型参数。

type 叫做类型别名,其实就是声明一个变量存储某个类型:

type Type = Promise<number>;

infer 用于类型的提取,推断一个类型,然后存到一个变量里,相当于局部变量:

type GetPromiseValueType<T extends Promise<any>> = T extends Promise<infer Type> ? Type : never

类型参数用于接受具体的类型,类似于 JS 中的形参,在类型运算中也相当于局部变量:

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

**但是,严格来说这三种也都不叫变量,因为它们不能被重新赋值,**想对类型做各种变换产生新的类型就需要重新构造。

数组类型

Push

有这样一个元组:

type tuple = [1,2,3];

我们想给这个元组再添加一种类型,怎么操作?

type tuple = [1,2,3];

// tuple = [1,2,3,4] ?

type Push<Arr extends  unknown[], Item> = [...Arr, Item];

unshift 也是一样的道理

type Unshift<Arr extends  unknown[], Item> = [Item,...Arr];

Zip

type tuple1 = [1, 2]
type tuple2 = ["bbb", "yyy"]
type tuple3 = [[1, "bbb"], [2, "yyy"]]

type Zip<Left extends [unknown, unknown], Right extends [unknown, unknown]> = Left extends [infer LeftItem1, infer LeftItem2]
    ?
    Right extends [infer RightItem1, infer RightItem2]
    ?
    [[LeftItem1, RightItem1], [LeftItem2, RightItem2]]
    : []
    : []
type tuple4 = Zip<tuple1,tuple2>

字符串类型

首字母大写

从已有的字符串类型中提取出一些部分字符串,经过一系列变换,构造成新的字符串类型。

type CapitalizeFirstLetter<T extends string> = T extends `${infer First}${infer Rest}` ? `${Uppercase<First>}${Rest}` : T

type Test = CapitalizeFirstLetter<'string'>

索引类型

添加只读

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

type AddReadOnly<O extends object> = {
    readonly [K in keyof O]: O[K];
}

type I2 = AddReadOnly<I1>

去掉只读

type DelReadOnly<O extends object> = {
    -readonly [K in keyof O]: O[K];
}

type I3 = DelReadOnly<I2>

Record

type MyRecord<T extends keyof any,V> = {[K in T]:V}

type test = MyRecord<symbol,any>
type test2 = Record<symbol,any>

递归循环

学会前面两种技巧后,已经可以做出大部分类型体操的题目了,但是面对一些,字符串长度不确定,数组元素不确定,对象层级不确定的情况,前面的两种显然是不够看的。

数组类型

ReverseArr

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

type test = ReverseArr<[1,2,3,4,5]>

字符串类型

字符替换

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;

type test = ReplaceAll<'aaaaadddssdssd','d','s'>

type-challenges 习题

StringJoin:组装字符串

type StringJoin<T extends Array<string>, U extends string> = T extends [infer Head, ...infer Rest]
  ? Head extends string
    ? Rest extends []
      ? Head
      : Rest extends Array<string>
        ? `${Head}${U}${StringJoin<Rest, U>}`
        : never
    : never
  : '';

// 类型为:'a-b-c'
type MyString = StringJoin<['a', 'b', 'c'], '-'>; 

下划线转驼峰

interface IUser {
    user_name: string;
    age: number;
    brith_date: number;
    user_compay_address: string;
    user_concat_info:{
        user_email:string;
        wechat_info:{
            wx_id: string,
            wx_name: string,
        }
    }
}

// 字符串首字母大写 Uppercase内置类型,可以将传入的字符串转为大写
type CapitalizeFirstLetter<T extends keyof any> = T extends `${infer First}${infer Rest}` ? `${Uppercase<First>}${Rest}` : T;

// 下划线转大写驼峰
type UnderscoreToCamelCase<T extends keyof any> = T extends `${infer First}_${infer Rest}` ? `${CapitalizeFirstLetter<First>}${UnderscoreToCamelCase<Rest>}` : CapitalizeFirstLetter<T>;

// 字符串首字母小写
type UncapitalizeFirstLetter<T extends keyof any> = T extends `${infer First}${infer Rest}` ? `${Uncapitalize<First>}${Rest}` : T;

// 深度转换
type ObjectUnderscoreToCamelCase<T extends Record<string, any>> = {
    [K in keyof T as UncapitalizeFirstLetter<UnderscoreToCamelCase<K>>]: T[K] extends Record<string, any>?
    ObjectUnderscoreToCamelCase<T[K]>
    :T[K]
}

type User = ObjectUnderscoreToCamelCase<IUser>

const user:User = {
    userName: 'string',
    age: 23,
    brithDate: 168212453,
    userCompayAddress: '广东省深圳市',
    userConcatInfo:{
        userEmail:'1369175442@qq.com',
        wechatInfo:{
            wxId: '1369175442',
            wxName: 'bbbbby',
        }
    }
}

参考资料