TS challenge整理高频用法

49 阅读6分钟

我们先看看平常使用的ts工具的实现

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

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

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

从上面是不是能看出一些端倪,比如[],in,keyof,extends,infer等等,本文再复习一下这些操作符的用法

索引类型

使用索引类型,编译器就能够检查使用了动态属性名的代码

索引类型查询操作符,keyof T

interface Car {
  manufacturer: string;
  model: string;
  year: number;
}
let carProps: keyof Car // 'manufacturer' | 'model' | 'year'

从上面的例子可以看出,keyof是集合了每个属性key的一个联合类型。事实上keyof Car 是完全可以和'manufacturer' | 'model' | 'year'相互替换的,但是使用keyof我们就可以动态获取类型Car的所有属性,比如Car可能会新增属性ownersAddress: string等等

当然这里还会有一些奇怪的地方,比如如果我们对基本类型使用keyof呢?比如keyof '',keyof 1,基本上我们不会这么使用,实际上这里使用了基本类型的对应构造函数的原型属性

let carProps: keyof ''
// let carProps: number | typeof Symbol.iterator | "toString" | "charAt" | "charCodeAt" | "concat" | "indexOf" | "lastIndexOf" | "localeCompare" | "match" | "replace" | "search" | "slice" | ... 35 more ... | "matchAll"

let carProps: keyof 1
// let carProps: "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"

索引访问操作符,T[K]

和我们在js中使用表达式语法a[b]很像,这里可以简单把操作的类型想象成一个对象

interface Car {
  manufacturer: string;
  model: string;
  year: number;
}
let carProps: Car['manufacturer']

我们可以直接在普通上下文中直接使用T[K],只要保证K extends keyof T就好了,我们可以再看一个稍微复杂的例子

interface Car {
  manufacturer: string;
  model: string;
  year: number;
}

let taxi: Car = {
  manufacturer: 'Toyota',
  model: 'Camry',
  year: 2014
}

function getProperty<T, K extends keyof T>(o: T, propertyName: K): T[K] {
  return o[propertyName]; // o[propertyName] is of type T[K]
}

let carProps: string = getProperty(taxi, 'model')
let year: number = getProperty(taxi, 'year');

字符串索引签名

在学习接口的时候我们见过这样的语法。我们回顾一下知识点

索引签名的参数类型必须为 number 或 string,数字索引的返回值必须是字符串索引返回值类型的子类型。 这是因为应当使用 number 来索引时,JavaScript 会将它转换成 string 然后再去索引对象。 也就是说用 100 (一个 number)去索引等同于使用 "100" (一个 string )去索引,因此两者需要保持一致。

interface Dictionary {
  [key: string]: string
}
let dictionary: Dictionary = {
  a: '',
  1: ''
}
type Dict = keyof Dictionary // string | number

映射类型 in操作符

常见的使用场景是将一个已知类型的每个属性都变成只读的,下面是Readonly工具的实现

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

来看看最简单的实现

type Keys = 'option1' | 'option2';
type Flags = { [K in Keys]: boolean };

我们可以发现in和索引签名类型一样,内部都使用了for...in,并且完成了以下三步操作

  • 获取一个联合类型的集合,包含了要迭代的所有属性名,这里采用的硬编码形式
  • 类型变量K依次绑定到每个属性
  • 约束属性的结果类型

实际就等同于

type Flags = {
    option1: boolean;
    option2: boolean;
}

真正的使用场景中不会干巴巴的使用联合类型,而基于已存在的类型,按照一定的方式进行转换。也就是我们上面看到的Readonly实现方式。in操作符搭配keyof和索引访问类型一起操作

条件类型 extends操作符

语法如下

T extends U ? X : Y

若 T 能够赋值给 U ,那么类型是 X ,否则为 Y

在实际使用中用到的大多都是条件类型的嵌套方式,比如下面一个简单的使用

type TypeName<T> =
    T extends string ? "string" :
    T extends number ? "number" :
    T extends boolean ? "boolean" :
    T extends undefined ? "undefined" :
    T extends Function ? "function" :
    "object";

分布式有条件类型

如果有条件类型里待检查的类型是裸类型,那么它也被称为 “分布式有条件类型”。

什么是裸类型呢?

是指类型参数没有被包装在其他类型里,比如没有被数组、元组、函数、Promise等等包裹,简而言之裸类型就是未经过任何其他类型修饰或包装的类型。

type TypeName<T> = T extends number ? "X" : "Y" ;
type H = TypeName<string | number> // "X" | "Y"

如果我们给类型包裹一下呢

type TypeName<T> = [T] extends [number] ? "X" : "Y" ;
type H = TypeName<string | number> // "Y"

从上面的例子我们能看出是否裸类型的区别,那么裸类型的返回值又应该怎么理解呢

分布式有条件类型在实例化的时候会自动分发成联合类型,例如实例化 T extends U ? X : Y , T 的类型为 A | B | C ,会被解析为 (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y) 。

我们看看一个工具类型的实现

type Exclude<T, U> = T extends U ? never : T;
type ExcludeTest = Exclude<'a' | 'b' | 'c' | 'd', 'a' | 'b'> // 'c' | 'd'

每次都拿联合类型'a' | 'b' | 'c' | 'd'中的某一个类型去和'a' | 'b'判断,是否是其子类型

条件类型中的类型推断 infer操作符

先看看ts工具ReturnType

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

现在在有条件类型的 extends 子语句中,允许出现 infer 声明,它会引入一个待推断的类型变量。 这个推断的类型变量可以在有条件类型的 true 分支中被引用。 允许出现多个同类型变量的 infer 。

使用infer的时候注意一下协变逆变的区别

当infer出现在协变位置上时,同一个类型变量的多个候选类型会被推断成联合类型

type Foo<T> = T extends { a: infer U, b: infer U } ? U : never;
type T10 = Foo<{ a: string, b: string }>;  // string
type T11 = Foo<{ a: string, b: number }>;  // string | number

当infer出现在协变位置上时,同一个类型变量的多个候选类型会被推断成交叉类型

type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>;  // string
type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>;  // string & number => never

模板字面量类型

模板字面类型建立在 字符串字面类型 之上,并且能够通过联合扩展成许多字符串。当模板字面量中有多个插值位置时,联合是交叉相乘

type World = "world";
// "hello world"
type Greeting = `hello ${World}`;

type EmailLocaleIDs = "welcome_email" | "email_heading";
type FooterLocaleIDs = "footer_title" | "footer_sendoff";
// "welcome_email_id" | "email_heading_id" | "footer_title_id" | "footer_sendoff_id"
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`;

模板字面进行推理

这个就用的很多了,我们先看一个TS challenge中的一个例子

type Space = ' ' | '\n' | '\t'
// 递归一层层实现
type TrimLeft<S extends string> = S extends `${Space}${infer R}` ? TrimLeft<R> : S
type Test = TrimLeft<'  str'>
let a: Test = 'str'

使用infer,我们能推断出剩余字符构成的新字符串字面量类型,结合递归和字符串的工具类型能完成很多意想不到的操作

字符串的工具类型有

  • Uppercase 将字符串中的每个字符转换为大写版本
  • Lowercase 将字符串中的每个字符转换为等效的小写字母
  • Capitalize 将字符串中的第一个字符转换为等效的大写字母
  • Uncapitalize 将字符串中的第一个字符转换为等效的小写字母