TypeScript体操运动员进阶指南

1,323 阅读24分钟

本文主要参考了typescript官网的TS指南,然后加上自己刷type-challenge(类型体操)的感受总结写的进阶指南。并不是官网指南的翻译版。

啰嗦两句:如果你连什么是接口、类型和泛型这些基础知识都不知道的话,就没必要往下了;如果类型体操你做easy以上都毫无压力也可以不用往下了。

有写得不对或者不清楚的地方,请评论告诉我。

先达成共识

很多人之所以会觉得TypeScript难的原因就在于:TS有时候很像JS,有时候又和JS完全不同。一开始很多人学习的时候,不把TS当成新的语言学习,而是简单的把TS当成JS的拓展来学习,实际学习之后发现有很多新知识点完全没接触过就会下意识觉得TS难。

咱们要把TS当成新的语言来学习,并且微软都不承认TS是JS的超集的说法。所以,在下面的学习中我尽量用JS给大家类比TS,实在不行的时候就需要多记多用才能学好这门语言,就像你最初学JS一样。

1.泛型

泛型是什么

首先,我们写一个identity函数,它的作用是你传入什么参数就返回什么参数。如果不使用泛型的话,我们可以这样写:

// 这种写法只能满足arg为number的情况

function identity(arg: number): number {
  return arg;
}

// 换种写法,使用any
function identity(arg: any): any {
  return arg;
}

虽然使用any类型可以实现identity的基本功能,因为它会导致函数接受 arg 类型的任何类型,但是当函数返回时,我们实际上丢失了关于该类型是什么的信息。如果我们传入一个数字,那么我们得到的唯一信息就是任何类型都可以返回,所以也是不完美的。

下面使用泛型来实现:

function identity<Type>(arg: Type): Type {
  return arg;
}

这个版本的identity函数就符合开头说的输入任意参数后原封不动的返回参数,也就是恒等函数。<Type>的作用就是去捕捉输入参数的类型。一般泛型是用T/U/V/P之类的命名,实际项目中如果多人开发可以将泛型命名得更具体一些比如:InputUISchema等等。

泛型函数/接口

当定义了以下泛型函数声明:

const myIdentity: <Type>(arg: Type) => Type;

此时定义myIdentity函数的形式,只有以下函数才可以赋值给myIdentity:

function identity<T>(arg:T):T {
  return arg
}

泛型还可以用在interface(接口)上:

// 一般接口这么写
interface GenericIdentityFn {
  arg: number;
}
// 泛型接口
interface GenericIdentityFn<Type> {
  (arg: Type): Type;
}

let myIdentity: GenericIdentityFn<number> = identity;

可以看出,上面的例子将泛型参数作为整个接口的参数。这让我们看到myIdentity的泛型最终是什么类型的(例如GenericIdentityFn<number>而不仅仅是GenericIdentityFn)。这使得类型参数对阅读接口的其他人的可读性大大增加。

2.类型操作符keyof + extends

TS handbook里keyof章节

在JS里实现在obj里返回某个key的value,通常会这么写:

function getOneObjValue (obj, key) {
   return obj[key]
}

但是其实这个函数有漏洞,如果输入的key不属于obj的话,函数就会返回undefined,可能导致后续代码出错。一般JS处理的方式是在函数里写if else把边缘case过滤掉,日积月累函数里可能有各种各样的if else相互嵌套,特别恶心。

在TS里的话,我们需要给函数加上类型:

function getOneObjValue (obj: T, key: keyof T):T[keyof T] {
   return obj[key]
}

这里的keyof T表示对T进行索引遍历查询,得到的类型是T的键名组成的模板字面量类型,此时的key的取值范围就被锁定为T中的key。如果输入不属于T的键名时,TS会报错。函数的返回值类型为T[keyof T](概念名为索引访问),也是类似JS里取对象的value时使用obj[key]一样。由于keyof自带遍历的特性,并不需要去写for循环依次取出,类似于JS里的object.keys()

举个例子:

// 定义一个接口
interface Person {
  "name": string,
  "age": number
}
type p1 = keyof Person; // "name" | "age"
type p2 = Person["name"]; //p2的类型为 string
type p3 = Person[keyof Person];//p3的类型为 string | number

下面穿插着看看模版字面量类型是什么:

// 模板字面量的语法和JS的模板字符串差不多
type EmailLocaleIDs = "welcome_email" | "email_heading";
type FooterLocaleIDs = "footer_title" | "footer_sendoff";
 
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`; 
// 此时的AllLocaleIDs的类型显示为:
type AllLocaleIDs = "welcome_email_id" | "email_heading_id" | "footer_title_id" | "footer_sendoff_id"

为了贯彻泛型,其实getOneObjValue函数还有改进的空间。keyof在函数中重复了两次,我们可以用一个泛型类型变量来代替它,下面才是最终我们会写出来的形式:

function getOneObjValue (obj: T, key: keyof T):T[keyof T] {
   return obj[key]
}

// 最终版
function getOneObjValue<T extends object, U extends keyof T> (obj: T, key: U):T[U] {
   return obj[key]
}

首先我们一般会使用T/P/U/V去表示一个泛型变量,那extends是什么意思呢?在TS里,像这样的类型编程里的extends和JS语言中类subclass extends class不是同一个extends,这里的A extends B可以简单理解为前者被后者约束或者是前者是后者的子集。在T extends objectobject是基本类型之一,也就是对象,所以可以理解为泛型T是对象的子集或者T被限制为对象类型,类似的U也好理解了。

思考题:{a:1} extends {}[1] extends []以及它们反过来是true还是false呢。

下面,假设此时不是去obj取一个值,而是取一组值,函数应该修改为如下:

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

// 数组
function getObjValueArr
<T extends object, U extends keyof T> (obj: T, keys: U[]):T[U][] {
  return keys.map((key) => obj[key]);
}

主要是将传入参数keys改为数组类型keys: U[],然后输出值的类型改为数组T[U][]。这里的基础知识在于:

// 定义number类型
let a:number = 1;
// 定义数组类型
let arr1:number[] = [1,2,3];

3.类型操作符typeof + 索引访问类型

类型操作符typeof

TS里typeof的用法和JS里的typeof差不多,看下面的对比:

// JS
console.log(typeof "Hello world"); // 输出string

//TS
let s = "hello";
let n: typeof s; // 表示n的类型是string

typeof会取后面变量(value)类型,但是注意这里的变量(value) 指的是非类型变量(value!),然后得到一个类型(Type!)完成类型声明。因为在TS的类型编程里并不能把非类型变量(值!)用在类型操作符里,所以我们需要typeof来做转换。

举个例子就明白了:

首先,我们来写一个函数类型(function type),也就是对一个变量进行函数定义的类型约束:

// Predicate是一个函数定义
type Predicate = (x: unknown) => boolean;
// ReturnType是TS的内置功能类型,可以直接用,它的作用是取函数类型返回值的类型
type K = ReturnType<Predicate>; // 此时K的类型是boolean

然后,我们再来写一个函数,然后把ReturnType用在函数名上:

function f() {
  return { x: 10, y: 3 };
}

// 会报错,因为类型操作符只能操作类型
type P = ReturnType<f>; 
// 利用typeof取类型
type P = ReturnType<typeof f>; // 此时 type P = { x:number; y:number }

对于typeof也可以用在下面的情况:

const MyArray = [
  { name: "Alice", age: 15 },
  { name: "Bob", age: 23 },
  { name: "Eve", age: 38 },
];
 
//此时 type Person = {name: string;age: number;}
type Person = typeof MyArray[number]; 

//此时 type Age = number
type Age = typeof MyArray[number]["age"]; 
// 也可以这样取number类型
type Age2 = Person["age"];

Person["age"]又是一个新的知识点,叫作索引访问类型(Index Access Type)。在TS里,我们可以使用一个索引访问类型来查找另一个类型上的特定属性:

type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"]; //此时Age的类型为number

有点类似于JS中取对象的值obj[key],在TS里则是取接口属性的类型定义。MyArray[number]表示取数组里所有的元素值(📢这里是不是类型),但是这里我们需要是是所有元素的类型,就在前面加上typeof就搞定了。typeof MyArray[number]["age"]表示在所有元素里取索引为"age"的类型。

在JS里,听到索引就会想到数组。而在TS里,我个人感觉索引访问类型就是为了处理数组/元组等类型而存在的,比如Type[number]typeof Type['某个属性']这种用法对于TS里的数组来说比较常见,大家在处理数组相关的就往索引访问类型上想就行了。

4.条件类型

条件类型是什么

条件类型

在JS里有if else,在TS里没有。TS的条件类型有点类似于JS的条件运算符..?..:

  SomeType extends OtherType ? TrueType : FalseType;

这个通常可以被用在函数重载上。不过如果不理解函数重载是什么的话没关系,看了下面的例子就懂了:

interface IdLabel {
  id: number;
}
interface NameLabel {
  name: string;
}
 
function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;

function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "unimplemented";
}

createLabel函数需要根据入参的不同产生不同的输出,这就叫函数重载。如果没有条件类型的话,我们需要一直重复定义出每一种场景下函数所需要的类型,无脑重复对于程序员来说无疑是一件不好的事。

仔细看createLabel函数其实是有三种重载场景idnamenameOrId,对于前两个类型是确定的返回值类型,对于nameOrId类型不确定输入的时候,函数返回值是IdLabel或者NameLabel。所以此时我们使用条件类型定义一个type来描述函数的返回值定义:

type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;

如果T的类型被约束为number就返回IdLabel,否则就返回NameLabel

然后,我们可以使用该条件类型将重载简化为单个函数,从而不再需要重载:

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}

let a = createLabel("typescript"); // a:NameLabel
let b = createLabel(2.8); // b:IdLabel
let c = createLabel(Math.random() ? "hello" : 42); // c: NameLabel | IdLabel

条件类型约束(Conditional Type Constraints)

条件类型约束可以使得推断出的类型更具体,就像使用类型守卫(后面详细讲)进一步缩小(也叫收窄narrowing,后面详细讲)类型范围可以给我们提供更具体的类型一样,条件类型里为true的那部分要执行的代码将进一步约束泛型。所以产生了条件类型约束的概念。

看下面的例子:

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

报错是因为此时的泛型T上并不一定存在message,要等用的人输入之后才知道有没有,所以我们需要约束一下T让代码不要报错。

一般情况下,不用条件类型约束时,大家会这样写:

type MessageOf<T extends { message: unknown }> = T["message"];

// 测试一下
interface Email {
  message: string;
}
type EmailMessageContents = MessageOf<Email>; // EmailMessageContents:string

但是,如果我们希望 MessageOf 可以输入任意类型而不是限制于{ message: unknown },并且在message属性不可用的情况下默认类型为 never,我们可以通过移除约束并引入条件类型来实现。

捋一捋:先判断泛型T是不是被{ message: unknown }约束,是的话就执行T["message"],不是的话就将默认为never类型。这不纯纯的条件类型:

type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;
 
interface Email {
  message: string;
}
 
interface Dog {
  bark(): void;
}
//type EmailMessageContents = string
type EmailMessageContents = MessageOf<Email>; 
//type DogMessageContents = never
type DogMessageContents = MessageOf<Dog>;

修改后的MessageOf比之前的更直观。

学会类型约束之后来做个小练习,写一个针对数组的Flatten类型,它的作用是将数组里的元素的类型提取出来,不是数组类型就直接返回当前的类型。又涉及到了是与不是,就要想到条件约束是不是可用:

type Flatten<T> = T extends any[] ? T[number] : T;
//也可以这样写any[]和Array<any>是两种表示数组的方法
type Flatten<T> = T extends Array<any> ? T[number] : T;


// 测试
type Str = Flatten<string[]>; // Str:string
type Num = Flatten<number>;// Num:number

这里的T[number]也就是我们前面讲过的索引访问类型,对于数组泛型来说T[number]表示取数组所有的元素,因为数组的索引是数字,number类型囊括了所有数字,这样也就取到了所有元素。

infer 关键字

上面的Flatten还有进步空间,至于为什么,等我们先学完infer这个关键字的用法再说。条件类型为我们提供了一种使用 infer 关键字从true的分支代码中得到类型结果的方法。(也就是说infer是只能用在条件类型中的)。

先看语法:

infer T;

infer T中的T一定要是一个待推断的类型变量,合在一起表示对T进行推断。

Flatten函数还可以这样写:

type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

这里的Item表示待推断的类型,此时的定义情况下是不知道Item到底是什么类型的。这个比上一个版本好在我们不需要通过索引访问类型的方式T[number]去思考如何取到数组的每一个元素的类型,引入一个新的Item泛型变量直接表示未来待传入(推断)数组元素的类型。

很多人对infer这个关键词可能会有点摸不着头脑,这里再啰嗦一下:

首先,infer是只服务于条件类型的。

其次,条件类型的表现就是根据输入的泛型条件将输出的类型的范围缩小,也就是说输入的类型是不确定的。对待不确定项,我们要么自己去推断:类似于Flatten函数里不知道输入数组的每个元素的类型,我们就用T[number]的方式取出每个元素类型;要么就引入新的泛型变量(类似于Item)去表示那些未知的元素类型,但是在前面必须加infer才能够说明Item是未知的带推断项。简单来说就像在JS里申明一个常量前面一定是加const才能够说明后面的变量是常量。

最后,实在不懂死记一下用法:infer只用在条件类型;infer后跟的是将要推断的泛型。

检验成果,我们来实现一个GetReturnType类型:利用infer实现内置泛型ReternType,它的作用是从函数类型中提取返回类型(这就是我们将要推断的泛型)。

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

type Str = GetReturnType<(x: string) => string>; //Str:string
type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>; //Bools:boolean[]

引入R表示要推断的泛型,在前面加上infer,如果输入的泛型T确实约束于函数类型(...args: never[]) => infer R则返回类型R,否则返回never

分布式条件类型

这个名字看起来有点高大上,其实就是在写条件类型的时候需要注意的一种情况,也可以称之为条件类型的特性吧。

当条件类型作用于泛型类型时,当传入泛型的是联合类型(union)时,这个时候条件类型会成为分布式类型。

根据上面的话,写一个例子:

// 下面类型的作用是将传入的泛型Type转化为数组类型
type ToArray<Type> = Type extends any ? Type[] : never;

// 传一个联合类型
type StrArrOrNumArr = ToArray<string | number>; //此时StrArrOrNumArr:string[] | number[]

传入Type的是联合类型string | number,然后分配为ToArray<string> | ToArray<number>,最终得出string[] | number[]的结果。这就是分布式条件类型。

如果不知道条件类型会分配的特性的话,可能会以为结果是(string | number)[]。那真要得到这个结果,需要做一些简单修改:

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

Type进行包裹之后,就不会产生分布式的情况了。

为了加深理解,一定要看下面的例子:

type Distributed<T> = T extends string ? "String!" : "Never!";
type a = Distributed<number | string>; // a: "String!" | "Never!"

怎么理解最后a的类型是"String!" | "Never!"(number | string) extends string经过分配之后可以理解为这个的形式(number extends string) | (string extends string),前者是false返回"Never!",后者是true返回"String!",合在一起就有了最后的结果。

上面的例子经常会把初学者搞懵,初学者按照JS的想法这样想❎:extends表示前者是后者的子集,(number | string) extends string一看前面就不是后面的子集,所以这里应该是false判断返回"Never!"一整个就大No特No,完全理解错了。

TS里有一个内置的工具函数Exclude<T,U>,就是利用这个实现的,下面来实现一下: Exclude的作用就是剔除联合类型T里包含的U类型。

分析:从T类型里去剔除U类型中的属性,那我们只需要让T中的属性一个个去和U比较,不属于U的就留下,属于U的用never类型代替。

type MyExclude<T, U> = T extends U ? never : T

// never
type a:string | never; // a:string

5.映射类型

映射类型

映射类型建立在索引代号(index signature)的语法之上,索引代号用于声明未提前声明的属性类型。

所以在将映射类型之前,先讲索引代号

当你想要申明一个类型或者接口,你只知道键和值类型,可以这样使用索引代号来代表:

type OnlyBoolsAndHorses = {
  [key: string]: boolean | Horse;
};
// 等价于
type OnlyBoolsAndHorses1 = Record<string, boolean | Horse>;
 
const conforms: OnlyBoolsAndHorses = {
  del: true,
  rodney: false,
};

类型OnlyBoolsAndHorses表示未知键名和数量,只知道键名的类型是string,键值的类型是boolean或者Horse。这种写法就叫作索引代号。此时也可以用TS内置的功能类型Record<Keys, Type>来表示,都是一个意思。

下面来说映射类型。假设现在需要写一个接口B,它和接口A有一样的属性,那么我们就需要使用映射类型将A的属性类型映射到B接口。当然,你也可以笨办法,手动再copy一份修改,但是这始终不是好的解决办法。

看下面的例子:

type OptionsFlags<Type> = {
  [Property in keyof Type]: boolean;
};

在上面的例子中,OptionsFlags 将获取 Type 类型中的所有属性,并将其值更改为布尔值。keyof Type前面讲过了是取Type里所有的属性,Property in 可以和JS里的for...in...类比,依次取所有Type属性的每一个,并将它约束为boolean类型。因为我们并不知道OptionsFlags未来的属性有什么,只能通过泛型Type推断出约束属性的定义,所以使用索引代号的方式[...]:...来表达OptionsFlags类型最终的定义。

可以看出TS里的每个语法都非常简洁,底层已经帮我们实现了大部分功能,不像JS语言那么繁琐(比如循环一定是有for或者while关键字),但是这样封装带来的坏处就是比较晦涩、不能一眼看懂。这和写JS还是有很大区别的,我们需要不停的练习去习惯这样的写法。

继续,我们来完成本节开头所说的将A的属性类型映射到B接口:

interface A {
  "name": string;
  "age": number;
}
type mappingAToB<T> = {
  [K in keyof T]: T[K];
};
let B:mappingAToB<A>; // 此时B的定义和接口A一致

根据上面的类型,此时我们可以写出TS的内置工具类型Partial,它的功能是将所有属性变为可选:

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

将可选符号?替换为readonly,就将所有属性变为只读了,又得到一个内置类型Readonly

type Readonly<T> = {
  readonly [K in keyof T]: T[k];
};

称热打铁,-符号可以用在?readonly前面,表示去除后面的属性。搭配一下-?就会得到另外一个内置类型:Required-?表示去除可选,那所有属性都变成必须了,这不就是Required!

type Required<T> = {
  [K in keyof T]-?: T[k];
};

兄弟集美萌,看到这里是不是觉得超级简单!!!没有问题的,你的TS已经向高级慢慢迈进了。

6.类型守卫Type Guards

前面将条件类型的时候,埋下了类型守卫的坑。这个词乍一听也挺高大上并且摸不着头脑,主要原因是因为对type guards做直译导致的,其实我自己理解之后觉得翻译为类型保护可能更好吧。其实类型守卫的意思就是在TS里利用一些手段将大范围的类型收窄为一个小范围的类型,这些手段在TS里会保证程序不会出错,它们就叫做类型守卫。

一些TS的关键字,比如is、typeof、in、instanceof都可以做类型守卫。简单来说typeof,类似JS里通过判断参数的类型来决定是否继续执行后面的代码,比如typeof a === 'string' 之后你才会继续用a.length

而在TS里,typeof是妥妥的类型守卫,因为它修正了JS里的一个错误:

function printAll(strs: string | string[] | null) {
  if (typeof strs === "object") { //null也是object
    for (const s of strs) { //报错:Object is possibly 'null'.
     console.log(s);
    }
  }
}

在JS里的null也是object,如果不用TS写的话,这里的代码后续是非常容易出问题的。所以在 TypeScript 中,根据 typeof 返回的值进行检查是一种类型保护(守卫)。

再举个简单的例子:

对于union(联合)类型,有时候会通过定义type来分开联合类型,其实也就是将联合类型收窄为单个类型

type Transportation = { type:'car'; car:number } | {type:'bus'; bus:number}

// 收窄
function tran(arg:Transportation) {
  // 类型守卫
  if(arg.type === 'car'){ 
    //此时类似收窄为{ type:'car'; car:number }
  } else { ... }
}

这里的arg.type也是一种类型保护的行为。

is关键字

接下来讲is来做类型保护。

type a: number | string;

如果你想将a类型收窄为string类型,此时沿用JS的逻辑,会使用typeof进行if else。我们先试试这样写:

//定义一个isString函数类型去判断是否是string类型,是就返回true
const isString = (arg: unknown): boolean => typeof arg === "string";

// 使用
function useIt(numOrStr: number | string) {
  if (isString(numOrStr)) {
    console.log(numOrStr.length); // 报错:number|string 上不含有length属性
  }
}

typeof arg === "string"是一种类型守卫,但是上面可以看出联合类型并没有因为isString改变,此时使用is去强行做判断,就可以达到收窄的效果:

const isString = (arg: unknown): arg is string => typeof arg === "string";

如果typeof arg === "string"就用is断定arg一定是string类型,此时isString函数类型就获得了和typeof arg === "string"一样的守卫能力,这个时候useIt函数就不会报错了。这种再封装方式也称作自定义类型守卫。

类型守卫在实际中可以用在检验外部未知数据,比如后端传到前端的数据。由于TS是在运行前进行预编译校验的,无法保证运行时的未知数据,此时就需要写一个自定义的类型守卫去保证程序正常的运行。

in 关键字

in关键字在之前讲keyof时已经用到过。在JS里也有in操作符,用来确定一个对象里是否含有某个属性:

var myObject = {
  foo: 1,
};

'foo' in myObject; // true

根据上述这一点,TS将其作为一种收窄潜在类型的方法。

举个例子:

type Fish = { swim: () => void };
type Bird = { fly: () => void };
 
function move(animal: Fish | Bird) {
  if ("swim" in animal) { // 利用in去排除掉联合类型上其他的类型
    return animal.swim();
  }
 
  return animal.fly();
}

需要注意一点就是可选属性会通过in的限制,这是in守卫不了的。

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
 
function move(animal: Fish | Bird | Human) {
  if ("swim" in animal) {
    animal; // (parameter) animal: Fish | Human
  } else {
    animal; // (parameter) animal: Bird | Human
  }
}

7.收窄类型的多种方式

收窄类型

TS的handbook专门有一章来讲在不同情况下怎么去收窄类型,里面有11个小节。上面讲类型守卫的时候讲到了其中的几种:typeof、in、is和可分开的联合类型,接下来讲剩余部分,里面的思想大部分和JS一模一样。

使用相等操作符

使用==,!=,===,!==去收窄类型:

1.使用全等===

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    x.toUpperCase(); // x:string
    y.toLowerCase(); // y:string
  } else {
    console.log(x); 
    console.log(y);
  }
}

2.使用==:

==符号在JS里属于不严格相等,在TS里也可以用来收窄类型。它对去除nullundefined有一个很好的效果,使用!=null的时候,不仅仅把null剔除类型,也会把undefined也剔除出去。对于undefined同样也是,!=undefined同样也剔除null

interface Container {
  value: number | null | undefined;
}
 
function multiplyValue(container: Container, factor: number) {
  //  'null' and 'undefined'同时被移除.
  if (container.value != null) {
    console.log(container.value); //(property) Container.value: number
 
    // 可以安全计算'container.value'了
    container.value *= factor;
  }
}

instanceof

这个和JS一模一样,不多说。

function logValue(x: Date | string) {
  if (x instanceof Date) {
    console.log(x.toUTCString()); // (parameter) x: Date
  } else {
    console.log(x.toUpperCase());// (parameter) x: string
  }
}

赋值

let x:number | string;
x = 1;
let a = x; // a:number
x = 'hello';
let b = x; // b:string
x = true; //Type 'boolean' is not assignable to type 'string | number'
let c = x; //c:number|string

赋值给一个类型范围比较广的变量时,TS会根据右侧的值适当的收窄左侧变量的类型范围。

但是要注意的是,每一次赋值TS都是根据最初声明(declare) 的类型来做收窄的,并不会因为赋值改变最初声明的类型,所以上面例子中c的类型为number|string

never

never 类型可以赋给每个类型,但是没有任何类型可以赋给 never (除了它本身)。利用这个特性可以对switch语句进行详尽的类型检查。

首先看JS的写法:

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  sideLength: number;
}
// ---cut---
type Shape = Circle | Square;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
  }
}

一般在JS里,如果没有默认需要处理的,default就不写,或者写的逻辑没办法兜底新增类型的情况。比如后来Shape同事需要新增一个接口Triangle,但是同事并不知道你之前写的switch用到了Shape这个类型,此时应该去靠default兜底。在TS里,就需要利用never类型做兜底。

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  sideLength: number;
}
interface Triangle {
  kind: "triangle";
  sideLength: number;
}

// ---cut---
type Shape = Circle | Square | Triangle;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _exhaustiveCheck: never = shape; // Triangle无法赋值给never类型
      return _exhaustiveCheck;
  }
}

default里加了这句 const _exhaustiveCheck: never = shape;shape在第一次case的时候收窄为Square | Triangle,第二次case的时候收窄为Triangle,所以会有Triangle无法赋值给never类型的报错。

如果没有Triangle类型,两次case导致类型收窄到没有类型,此时shape的类型就变成never了,又刚好never可以赋值给never类型,所以不会报错。

再复习一遍:never 类型可以赋给每个类型,但是没有任何类型可以赋给 never (除了它本身)。咋一看觉得never这个类型很多余,这个例子就有助于我们去理解never这个类型的作用。

如果在项目中,对于某个大范围type,需要保证里面每一个类型都要被检查的话(也称作无穷尽检查),就需要结合never的特性像上面那样使用。

总结

其实TS不难,主要是很多专业名词比较陌生,但是只要能够和JS的概念对上就比较好理解了。TS的一些类型操作符和JS一样,也有一些虽然同名但是概念和用法不一致的。

我自己比较适应的方式就是刷类型体操的时候,先想想如果用JS的话应该怎么实现,然后再对应上TS去实现,这样前期对于入门的同学比较友好。

这是我的type-challenge记录,会持续更新并对每道题进行分析。