All articles

TS4 - Type Advanced Usage to

Feb 09, 2023

This article mainly records the advanced usage of the TypeScript type system. The main contents are as follows: interface and type alias, literal type and keyof, type protection and custom type protection, generics, type compatibility, mapping, conditional type and infer, etc. .

前言

TypeScript 带来最显著的变化就是对类型的约束,对于类型系统的简单用法(类型声明、类型注解、类型推断、类型分类、类型断言、对象类型、索引签名)不作过多的说明本章主要对一下内容进行一个学习:

  • 接口与类型别名区别
  • 类型保护与自定义类型保护
  • 定义泛型和泛型常见操作
  • 类型兼容性详解
  • 映射类型与内置工具类型
  • 条件类型和 infer 关键字
  • 类中如何使用类型

接口与类型别名区别

接口

接口是一系列抽象方法的声明,是一些方法特征的集合。简单来说,接口的作用就是为这些类型命名和为你的代码或第三方代码定义契约。

接口跟类型别名类似都是用来定义类型注解的,接口是用interface关键字来实现的,如下:

interface A {
  username: string
  age: number
}
let a: A = {
  username: 'smitish',
  age: 20,
}

因为接口跟类型别名功能类似,所以接口也具备像索引签名,可调用注解等功能。

interface A {
  [index: number]: number
}
let a: A = [1, 2, 3]

interface A {
  (): void
}
let a: A = () => {}

接口与别名的区别

接口和类型别名除了具备一些相似的功能之外,还具备一些的区别。

  1. 对象类型
  2. 接口合并
  3. 接口继承
  4. 映射类型

对象类型

**类型别名可以操作任意类型,接口只能操作对象类型。**可以从定义方式看的出来接口 interface User {} 只能操作对象,而类型别名 type User = {} 等号后面可以是任意的内容。

接口合并

类型别名不可以进行合并,接口可以进行合并。

interface A {
  username: string
}
interface A {
  age: number
}
let a: A = {
  username: 'smitish',
  age: 20,
}

接口继承

interface A {
  username: string
}
interface B extends A {
  age: number
}
let b: B = {
  username: 'smitish',
  age: 20,
}

B 这个接口继承了 A 接口,所以 B 类型就有了 username 这个属性。在指定类型的时候,b 变量要求同时具备 A 类型和 B 类型。

映射类型

接口不具备定义成接口的映射类型,而别名是可以做成映射类型的。

type A = {
  // success
  [P in 'username' | 'age']: string
}
type A = {
  username: string
  age: string
}
interface A {
  // error
  [(P in 'username') | 'age']: string
}

类型保护与自定义类型保护

类型保护

类型保护允许你使用更小范围下的对象类型。这样可以缩小类型的范围保证类型的正确性,防止 TS 报错。这段代码在没有类型保护的情况下就会报错,如下:

function foo(n: string | number) {
  n.length // error
}

因为 n 有可能是 number,所以 TS 会进行错误提示,可以利用类型断言来解决,但是这种方式只是欺骗 TS,如果在运行阶段还是可能报错的,所以并不是最好的方式。利用类型保护可以更好的解决这个问题。

类型保护的方式有很多种,主要是四种方式:

  1. typeof 关键字实现类型保护
function foo(n: string | number) {
  if (typeof n === 'string') {
    n.length // success
  }
}
  1. instanceof 关键字实现类型保护,主要是针对类进行保护的
class Foo {
  username = 'xiaoming'
}
class Bar {
  age = 20
}
function baz(n: Foo | Bar) {
  if (n instanceof Foo) {
    n.username
  }
}
  1. in 关键字实现类型保护,主要是针对对象的属性保护
function foo(n: { username: string } | { age: number }) {
  if ('username' in n) {
    n.username
  }
}
  1. 字面量类型保护
function foo(n: 'username' | 123) {
  if (n === 'username') {
    n.length
  }
}

自定义类型保护

除了以上四种方式可以做类型保护外,如果我们想自己去实现类型保护可行吗?答案是可以的,只需要利用is关键字即可, is为类型谓词,它可以做到类型保护。

// 返回 true 说明 n 是 string
function isString(n: any): n is string {
  return typeof n === 'string'
}
function foo(n: string | number) {
  if (isString(n)) {
    n.length
  }
}

定义泛型和泛型常见操作

定义泛型

泛型是指在定义函数、接口或者类时,未指定其参数类型,只有在运行时传入才能确定。泛型简单来说就是对类型进行传参处理。

type A<T = string> = T // 泛型默认值
let a: A = 'hello'
let b: A<number> = 123
let c: A<boolean> = true

这里可以看到通过<T>来定义泛型,还可以给泛型添加默认值<T=string>,这样当我们不传递类型的时候,就会已 string 作为默认的类型进行使用。

泛型还可以传递多个,实现多泛型的写法。

type A<T, U> = T | U // 多泛型

数组有两种定义方式,除了基本定义外,还有一种泛型的写法:

let arr: Array<number> = [1, 2, 3]
// 自定义MyArray实现
type MyArray<T> = T[]
let arr2: MyArray<number> = [1, 2, 3]

泛型在函数中的使用:

function foo<T>(n: T) {}
foo<string>('hello')
foo(123) // 泛型会自动类型推断

泛型跟接口结合的用法:

interface A<T> {
  (n?: T): void
  default?: T
}
let foo: A<string> = (n) => {}
let foo2: A<number> = (n) => {}
foo('hello')
foo.default = 'hi'
foo2(123)
foo2.default = 123

泛型与类结合的用法:

class Foo<T> {
  username!: T
}
let f = new Foo<string>()
f.username = 'hello'

class Foo<T> {
  username!: T
}
class Baz extends Foo<string> {}
let f = new Baz()
f.username = 'hello'

有时候也会对泛型进行约束,可以指定哪些类型才能进行传递:

type A = {
  length: number
}
function foo<T extends A>(n: T) {}
foo(123) // error
foo('hello')

通过 extends 关键字可以完成泛型约束处理。

类型兼容性详解

类型兼容性

类型兼容性用于确定一个类型是否能赋值给其他类型。如果是相同的类型是可以进行赋值的,如果是不同的类型就不能进行赋值操作。

let a: number = 123
let b: number = 456
b = a // success

let a: number = 123
let b: string = 'hello'
b = a // error

当有类型包含的情况下,又是如何处理的呢?

let a: number = 123
let b: string | number = 'hello'
b = a // success
a = b // error

变量 a 是可以赋值给变量 b 的,但是变量 b 是不能赋值给变量 a 的,因为 b 的类型包含 a 的类型,所以 a 赋值给 b 是可以的。

在对象类型中也是一样的处理方式,代码如下:

let a: { username: string } = { username: 'xiaoming' }
let b: { username: string; age: number } = {
  username: 'xiaoming',
  age: 20,
}
a = b // success
b = a // error

b 的类型满足 a 的类型,所以 b 是可以赋值给 a 的,但是 a 的类型不能满足 b 的类型,所以 a 不能赋值给 b。所以看下面的例子就明白为什么这样操作是可以的。

function foo(n: { username: string }) {}
foo({ username: 'xiaoming' }) // success
foo({ username: 'xiaoming', age: 20 }) // error
let a = { username: 'xiaoming', age: 20 }
foo(a) // success

这里把值存成一个变量 a,再去进行传参就是利用了类型兼容性做到的。

扩展:协变、双向协变。推荐阅读:聊聊 TypeScript 类型兼容,协变、逆变、双向协变以及不变性

映射类型与内置工具类型

映射类型

可以将已知类型的每个属性都变为可选的或者只读的。简单来说就是可以从一种类型映射出另一种类型。这里我们先要明确一点,映射类型只能用类型别名去实现,不能使用接口的方式来实现。

先看一下在 TS 中是如何定义一个映射类型的。

type A = {
  username: string
  age: number
}
type B<T> = {
  [P in keyof T]: T[P]
}
type C = B<A>

这段代码中类型 C 与类型 A 是完全一样的,其中in关键字就类似于一个for in循环,可以处理 A 类型中的所有属性记做p,然后就可以得到对应的类型T[p]

那么我们就可以通过添加一些其他语法来实现不同的类型出来,例如让每一个属性都是只读的,可以给每一项前面添加readonly关键字。

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

内置工具类型

每次我们去实现这种映射类型的功能是非常麻烦的,所以 TS 中给我们提供了很多常见的映射类型,这些内置的映射类型被叫做,内置工具类型。

Readonly 就是跟我们上边实现的映射类型是一样的功能,给每一个属性做成只读的。

type A = {
  username: string
  age: number
}
/* type B = {
    readonly username: string;
    readonly age: number;
} */
type B = Readonly<A>

Partial 可以把每一个属性变成可选的。

type A = {
  username: string
  age: number
}
/* type B = {
    username?: string|undefined;
    age?: number|undefined;
} */
type B = Partial<A>

Pick 可以把某些指定的属性给筛选出来。

type A = {
  username: string
  age: number
  gender: string
}
/* type D = {
    username: string;
    age: number;
} */
type D = Pick<A, 'username' | 'age'>

Record 可以把字面量类型指定为统一的类型。

/* type E = {
    username: string;
    age: string;
} */
type E = Record<'username' | 'age', string>

Required 可以把对象的每一个属性变成必选项。

type A = {
  username?: string
  age?: number
}
/* type B = {
    username: string;
    age: number;
} */
type B = Required<A>

Omit 是跟 Pick 工具类相反的操作,把指定的属性进行排除。

type A = {
  username: string
  age: number
  gender: string
}
/* type D = {
    gender: string
} */
type D = Omit<A, 'username' | 'age'>

Exclude 可以排除某些类型,得到剩余的类型。

// type A = number
type A = Exclude<
  string | number | boolean,
  string | boolean
>

我们的内置工具类型还有一些,如:Extract、NonNullable、Parameters、ReturnType 等,下一个小节中将继续学习剩余的工具类型。

条件类型和 infer 关键字

在上一个小节中,学习了 Exclude 这个工具类型,那么它的底层实现原理是怎样的呢?

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

这里可以看到 Exclude 利用了 ? : 的写法来实现的,这种写法在 TS 类型中表示条件类型,让我们一起来了解下吧。

条件类型

条件类型就是在初始状态并不直接确定具体类型,而是通过一定的类型运算得到最终的变量类型。

type A = string
type B = number | string
type C = A extends B ? {} : []

条件类型需要使用extends关键字,如果 A 类型继承 B 类型,那么 C 类型得到问号后面的类型,如果 A 类型没有继承 B 类型,那么 C 类型得到冒号后面的类型,当无法确定 A 是否继承 B 的时候,则返回两个类型的联合类型。

那么大多数情况下,条件类型还是在内置工具类型中用的比较多,就像上面的 Exclude 方法,下面就让我们一起看一下其他内置工具类型该如何去用吧。

Extract 跟 Exclude 正好相反,得到需要筛选的类型。

// type Extract<T, U> = T extends U ? T : never  -> 实现原理
// type A = string
type A = Extract<string | number | boolean, string>

NonNullable 用于排除 null 和 undefined 这些类型。

//type NonNullable<T> = T extends null | undefined ? never : T;  -> 实现原理
//type A = string
type A = NonNullable<string | null | undefined>

Parameters 可以把函数的参数转成对应的元组类型。

type Foo = (n: number, m: string) => string
//type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;   -> 实现原理
// type A = [n: number, m: string]
type A = Parameters<Foo>

在 Parameters 方法的实现原理中,出现了一个infer关键字,它主要是用于在程序中对类型进行定义,通过得到定义的 p 类型来决定最终要的结果。

ReturnType 可以把函数的返回值提取出类型。

type Foo = (n: number, m: string) => string
//type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;   -> 实现原理
//type A = string
type A = ReturnType<Foo>

这里也通过infer关键字定义了一个 R 类型,对应的就是函数返回值的类型。通过infer关键字可以在泛型之外也可以定义类型出来。

下面再利用infer来实现一个小功能,定义一个类型方法,当传递一个数组的时候返回子项的类型,当传递一个基本类型的时候就返回这个基本类型。

type A<T> = T extends Array<infer U> ? U : T
// type B = number
type B = A<Array<number>>
// type C = string
type C = A<string>

这里的 U 就是自动推断出的数组里的子元素类型,那么就可以完成我们的需求。

类中如何使用类型

本小节主要讲解在类中如何使用 TS 的类型,对于类的一些功能使用方式,例如:类的修饰符、混入、装饰器、抽象类等等并不做过多的介绍。

类中定义类型

属性必须给初始值,如果不给初始值可通过非空断言来解决。

class Foo {
  username!: string
}

给初始值的写法如下:

class Foo {
  //第一种写法
  //username: string = 'xiaoming';
  //第二种写法
  // username: string;
  // constructor(){
  //   this.username = 'xiaoming';
  // }
  //第三种写法
  username: string
  constructor(username: string) {
    this.username = username
  }
}

类中定义方法及添加类型也是非常简单的。

class Foo {
  ...
  showAge = (n: number): number => {
    return n;
  }
}

类使用接口

类中使用接口,是需要使用implements关键字。

interface A {
  username: string
  age: number
  showName(n: string): string
}

class Foo implements A {
  username: string = 'xiaoming'
  age: number = 20
  gender: string = 'male'
  showName = (n: string): string => {
    return n
  }
}

在类中使用接口的时候,是一种类型兼容性的方式,对于少的字段是不行的,但是对于多出来的字段是没有问题的,比如说 gender 字段。

类使用泛型

class Foo<T> {
  username: T
  constructor(username: T) {
    this.username = username
  }
}
new Foo<string>('xiaoming')

继承中用的也比较多。

class Foo<T> {
  username: T
  constructor(username: T) {
    this.username = username
  }
}
class Bar extends Foo<string> {}

最后来看一下,类中去结合接口与泛型的方式。

interface A<T> {
  username: T
  age: number
  showName(n: T): T
}
class Foo implements A<string> {
  username: string = 'xiaoming'
  age: number = 20
  gender: string = 'male'
  showName = (n: string): string => {
    return n
  }
}

antcao.me © 2022-PRESENT

: 0x9aB9C...7ee7d