TS体操入门

134 阅读7分钟

导读

工作现在的做的大部分前端项目都是使用ts编写的,但平时工作时只是把ts当成类型标记语言来编写,对于花里胡哨的ts构造与定义经常抓瞎。本文从最基础的ts基础开始,复习巩固一些ts最基本的概念和语法,同时介绍一些常见的ts体操方法,来走入TS体操的大门。

TS基础

父类型与子类型

在ts类型系统中,子类型并不需要显式,对父子类型满足如下关系

  1. 子类型比父类型更加具体,父类型比子类型更抽象

  2. 子类型可以赋值给父类型

对于第一点,是否具体和抽象,需要区分情况来看。比较简单的一个例子就是对于接口和联合类型的处理不同,看以下例子

// 父类型
interface Animal {
    weight: number;
}
// 子类型
interface Dog {
    weight: number;
    bark: () => void;
}

// 父类型
type Animals = 'dog' | 'eagle' | 'seagull'
// 子类型
type Birds = 'eagle' | 'seagull'

首先看interface Animal和Dog这个例子。Animal只有一个weight体重属性,比较宽泛,每一个动物都会有体重。而Dog除了weight之外,还多了一个bark属性,说明我们的Dog会叫了,而不是所有的Animal都会叫。所以Dog类型更加具体了,这里的Dog就是Animal的子类型。

而对于第二个联合类型的例子,乍一看起来好像是Animals里属性比Birds多,Animals应该是子类型,但实际正相反。因为对于Animals来说,dog、eagle、seagull这几个值他都可以接受,而Birds则只能接eagle、seagull这两个值,更加具体了,所以Birds是Animals的子类型。

而对于第二点,我们看另一个例子

// 这里的Animal和Dog定义同上文保持一致

// 子类型
type AnimalHandler = (animal:Animal) => void 
// 父类型
type DogHandler = (dog:Dog) => void 

let animalHandler: AnimalHandler;
let dogHandler: DogHandler;

animalHandler = dogHandler // error
dogHandler = animalHandler // ok

这个例子乍一看谁更具体谁更抽象看不太出来,因此我们从可赋值性来考虑。现在如果将animalHandler赋值给dogHandler,也就是说原本应该接受一个Dog参数的函数变成了接受一个Animal参数,如果我们在函数中调用了参数的bark()方法,显然Animal参数是满足不了的,不能赋值。如果将dogHandler赋值给animalHandler,Animal能做到的Dog一定能做到,可以满足赋值。所以在这里DogHandler是AnimalHandler的父类型。

逆变与协变

逆变与协变的定义如下

协变与逆变(covariance and contravariance)是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语。

定义有点晦涩,但也算好理解,我们可以换个说法

具有父子关系的类型,在经过类型构造处理后,所产生的新类型之间父子关系是否颠倒。如果父子关系颠倒,就是发生了逆变未颠倒则是发生了协变

我们在上边举的例子中Animal => AnimalHandler、Dog =>DogHandler这个操作,Handler之间和原本的Animal和Dog之间的父子关系发生了颠倒,就是一个逆变操作,即type ToFunctionParam<T> = (arg:T) => void 这个构造。

协变操作也很好理解,例如函数的返回值type ToReturnType<T> = () => T 就是一个协变操作。

对于父子类型和协变逆变也可以参考github.com/sl1673495/b…

常用关键字

any、unknown与never

never是所有类型的子类型,可赋值给所有类型。但是没有类型可以赋值给never,即使是any也不行。

同时never还有一个很有意思的特点,在联合类型中never会被当做一个空类型,可以理解为一个单独的 | ,看下方样例

let neverValue: never;
neverValue = 'never' as any; // error Type 'any' is not assignable to type 'never'
neverValue = 'never' as never; 

let num:number = neverValue; // ok


type ab = 'a' | 'b'| never; // 结果为 'a'|'b'

unknown是所有类型的父类型,所有类型可以给unknown赋值。但是unknown类型当赋值给其他类型(any除外)时,会进行类型检查,要求将unknow收窄到具体的类型

let unknownValue: unknown = 'str'; // ok
unknownValue = 123; //ok

let numValue: number = unknownValue //error Type 'unknown' is not assignable to type 'number'
let numValue: number = unknownValue as number // ok

let anyValue: any = unknownValue //ok

any代表任何类型,和unknown很像,但是不会进行类型收窄,比unknown更宽松。也就是说所有类型(包括never与unknown)都可以给any赋值,any可以给所有类型赋值(除了上文提到的never)

let anyValue: any = 'value' as unknown // ok
anyValue = 'value' as never // ok

let nerverValue: never = anyValue //error Type 'any' is not assignable to type 'never'

extends

extends在ts中主要有两个作用

  1. 接口继承

这个比较好理解,看如下例子

interface T1 { name: string  }   
interface T2 {  sex: number  }    

// {name:string; sex:number; age:number}
interface T3 extends T1,T2 { age: number }
  1. 条件判断

ts体操中最常见的用法,用法和三元表达式很像,A extends B ? T : P ,判断A是否能赋值给B,也即是A是否是B的子类型。如果能则返回T,不能则返回P,举几个例子

type isNumber<T> = T extends number ? true : false
isNumber<number> // true
isNumber<1> // true

type test<T> = T extends {} ? true : false // 同样,如果这里的{}换成unknown也总会返回true
test<string> // true
test<number> // true

对于联合类型的处理,extends的处理比较特殊。在 A extends B 中,如果A是一个联合类型,则会将A拆分开一个一个去执行extends操作,最后将拆分执行出的结果联合起来,看几个例子

// isNumber定义同上
type t1 = isNumber<number|string> //结果是boolean,即true|false

// 内置工具Exclude,作用是从联合类型中排除几个类型来构造新类型
type Exclude<T, U> = T extends U ? never : T;
type test = Exclude<'a'|'b'|'c'|'d','a'|'b'> // never|never|'c'|'d',即'c'|'d'

如果不想让联合类型被拆分,可以用[]套起来

type t1 = isNumber<[number|string]> //false

infer

用来推断类型,只能用于extends关键词中,表示在 extends 条件语句中待推断的类型变量,例如内置的Parameters和ReturnType工具类型

type Parameters<T extends (...args: any) => any> = 
    T extends (...args: infer P) => any ? P: never
type ReturnType<T extends (...args: any) => any> = 
    T extends (...args: any) => infer R ? R : any;    

keyof

keyof T 会得到由 T 上已知的公共属性名组成的联合类型

type test = keyof {a:number;b:number} // 'a'|'b'

in

用于遍历联合类型,类似与for ... In,例如内置的Partial工具类型,将所有属性变成可选

type Partial<T> = {
    [P in keyof T]?: T[P]; // P will be each key of T
}

体操常见方法

类型提取

使用extends和infer从复杂类型中提取出需要,通常按照语法返回infer推测出的值即可

// 推测Promise的返回值
type UnPromisefy<T extends Promise<unknown>> = T extends Promise<infer P> ? P :never 
type test = UnPromisefy<Promise<number>> // number

对于数组类型可以使用...解构符来操作

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

类型扩展

基于原有的类型做变换,构造出一个新的类型

// 字符串首字母大写
type CapitalizeStr<Str extends string> = 
    Str extends `${infer First}${infer Rest}` ? `${Uppercase<First>}${Rest}` : Str;
// 添加函数的入参类型(新构造的函数会丢掉参数名)
type AppendArgument<Fn extends Function, Type> = 
    (...args: [...Parameters<Fn>, Type]) => ReturnType<Fn>

这里特别提一下对于函数参数的处理,通常对于函数参数的操作是使用...解构(不解构ts是推断不出来的),或者直接使用内置的Parameters工具类,处理的效果如下

image

这里的[a: number, b:number]有命名的元组(labled/named tuple),等效于[number, number],使用起来也是和元组一样用index来访问,而并不能用他的key直接访问,可以参考What are “named or labeled tuples” in Typescript?,关于ts中的数组和元祖可以参考Arrays & Tuples — Type-Level TypeScript

递归循环

ts中虽然有in循环,但只能用在联合类型上,使用起来相对有局限。所以通常循环需要使用递归来实现,尤其是对于数组和字符串的处理

// 下划线转小驼峰
type ToCamelCase<Str extends string> = 
    Str extends `${infer Left}_${infer Right}${infer Rest}` ? 
        `${Left}${Uppercase<Right>}${ToCamelCase<Rest>}` 
        : Str    
// 数组是否包含某类型
type Includes<Arr extends unknown[], FindItem> = 
    Arr extends [infer First, ...infer Rest]
        ? IsEqual<First, FindItem> extends true
            ? true
            : Includes<Rest, FindItem>
        : false;

type-challenges

了解了ts体操的基础知识,就可以开始做体操啦

题目在github.com/type-challe…

可以在www.typescriptlang.org/zh/play 这个playground中做题

祝大家做操愉快