TypeScript 进阶

泛型

泛型函数与泛型接口

很多时候我们希望一个函数或者一个类支持多种数据类型,有很大的灵活性。我们可以用以下方式实现。

// 函数支持传入多种类型
// 1. 函数重载实现
function log1(value: string): string
function log1(value: string[]): string
function log1(value: any) {
  console.log(value)
  return value
}
// 2. 联合类型实现
function log2(value: string | string []): string | string[] {
  console.log(value)
  return value
}
// 3. 支持任意类型 any。 any 类型会丢失类型信息,没有类型之间的约束关系
function log3(value: any) {
  console.log(value)
  return value
}
ts

似乎 any 类型已经满足我们的需求,但是当一个调用者调用函数的时候,完全无法获取约束关系,这个时候就需要用到泛型。

泛型:不预先确定的数据类型,具体的类型在使用的时候才能确定。

function log4<T>(value: T): T {
  console.log(value)
  return value
}
// 显式声明
log4<String[]>(['a', 'b', 'c'])
// 利用 ts 类型推断,直接传入数组,推荐使用
log4(['a', 'b', 'c'])
ts

我们不仅可以利用泛型定义函数,还可以定义一个类。

type Log = <T>(value: T) => T
const logger: Log = log1
ts

泛型同样也可以使用在接口中。

// 泛型接口
// 这种实现和类型别名的方式是等价的
interface Log{
  <T>(value: T): T
}
// 上述泛型实现仅仅约束了一个函数,我们也可以用泛型约束接口其他成员
// 当使用泛型约束整个接口之后,实现的时候必须指定一个类型或者在接口的定义中指定一个默认类型
interface Log2<T = string> {
  (value: T): T
}
const logger: Log2<number> = log4
logger(1)
const looger2: Log2 = log4
looger2('1')
ts

泛型对于前端开发来说是一个比较新的概念,泛型在 ts 的高级类型中有广泛的应用。

技术拓展

type Log = <T>(value: T) => T;
type Log<T> = (value: T) => T;

interface Log {
  <T>(value: T):T
}
interface Log<T> {
  (value: T):T
}

// 1、3是等价的,使用时无需指定类型。
// 2、4是等价的,使用时必须指定类型
ts

泛型类与泛型约束

泛型可以用来约束类的成员。

class Log<T> {
  run(value: T) {
    console.log(value)
    return value
  }
  // Static members cannot reference class type parameters.
  // 泛型不能应用于类的静态成员
  // static run(value: T) {
  //   console.log(value)
  //   return value
  // }
}
// 指定类型参数
const logger1 = new Log<number>();
logger1.run(1)
// 我们也可以不指定类型参数,这时 value 的值可以是任意的值
const logger2 = new Log()
logger2.run({ a: 1 })
logger2.run('2')
ts

泛型定义约束关系。

// 泛型约束
interface Length {
  length: number
}
// 当我们不仅想使用 value,还想使用 value.length
function log<T extends Length>(value: T): T {
  console.log(value, value.length)
  return value
}
// 当我们使用 T 继承 Length 之后,就添加了约束,就不是所有类型都可以传了,输入的类型必须带有 length 属性
log('1')
// log(1) // Argument of type 'number' is not assignable to parameter of type 'Length'.
log([1])
// log({}) // Argument of type '{}' is not assignable to parameter of type 'Length'.
ts

总结

使用泛型的好处:

  • 函数和类可以轻松地支持多种类型,增强程序的扩展性
  • 不必写多条函数重载,冗长的联合类型声明,增强代码可读性
  • 灵活控制类型之间的约束关系

泛型不仅可以保持类型的一致性,又不失程序的灵活性,同时也可以通过泛型约束,控制类型之间的约束。从代码的上来看,可读性,简洁性,远优于函数重载,联合类型声明以及 any 类型的声明。

类型检查机制

类型检查机制,就是 TypeScript 编译器在做类型检查时,所秉承的一些原则,以及表现出的一些行为。

类型检查机制可以用来辅助开发,提高开发效率。

  • 类型推断
  • 类型兼容性
  • 类型保护

类型推断

不需要指定变量的类型(函数的返回值类型),TypeScript 可以根据某些规则自动地为其推断出一个类型。

  • 基础类型推断
  • 最佳通用类型推断
  • 上下文类型推断
// 类型推断

// 根据表达式值推断表达式类型,
// 基础类型推断
let a = 1 // number
let b = [] // any[]
let c = [1] // number[]
const d = (x = 1) => {} // const d: (x?: number) => void
const e = (x = 1) => x // const e: (x?: number) => number

// 最佳通用类型推断,从右到左
let c1 = [1, null, '1'] // let c1: (string | number)[]

// 上下文类型推断,从左到右
// ts 根据左侧事件绑定,推断右侧事件类型,(parameter) event: KeyboardEvent
window.onkeydown = (event) => {}
ts
// 类型断言
// 类型断言可以增加代码灵活性,改造旧代码很有效,但是不能滥用类型断言
// 你需要对上下文的环境有充分的预判,没有任何根据的类型断言会给代码带来安全隐患

interface Foo {
  bar: number
}

// 类型断言配合接口类型
const foo = {} as Foo
foo.bar = 1
ts

ts 类型推断可以我们提供重要的辅助信息。

类型兼容性

当一个类型 Y 可以被赋值给另一个类型 X 时,我们就可以说类型 X 兼容类型 Y。

X 兼容 Y:X(目标类型)= Y(源类型)

结构之间兼容:成员少的兼容成员多的。
函数之间兼容:参数多的兼容参数少的

// 类型兼容性

// strictNullChecks false 情况下,字符串类型兼容 null 类型
// null 是 字符串类型的子类型,之所以要讨论这些兼容性问题,是因为 ts 允许把一些类型不同的变量相互赋值
// 虽然在某种程度来讲,可能会产生一些不可靠的行为,但可以增加语言的灵活性
let s: string = 'a'
s = null

// 类型兼容性的例子广泛存在于接口、函数、类中

// 接口兼容
interface X {
  a: any
  b: any
}
interface Y {
  a: any
  b: any
  c: any
}
let x: X = { a: 1, b: 2 }
let y: Y = { a: 1, b: 2, c:3 }
x = y 
// y = x // Property 'c' is missing in type 'X' but required in type 'Y'.
// Y 接口具备 X 接口所有的属性,Y 可以被视为 X 类型,X 类型可以兼容 Y 类型
// 源类型必须具备目标类型的必要属性,就可以进行赋值,接口之间相互兼容,成员少的会兼容成员多的

// 函数兼容性
// 判断函数的兼容性,一般是在函数作为参数的情况下
type Handler = (a: number, b: number) => void
function hof(handler: Handler) {
  return handler
}

// 函数兼容需要满足三个条件
{
  // 1. 参数个数要求
  // 参数个数:目标函数的参数个数一定要多余原函数的参数个数
  let handler1 = (a: number) => {}
  hof(handler1)
  let handler2 = (a: number, b: number, c: number) => {}

  // 可选参数和剩余参数:
  let a = (p1: number, p2: number) => {}
  let b = (p1?: number, p2?: number) => {}
  let c = (...args: number[]) => {}
  // 1) 固定参数可以兼容可选参数和剩余参数
  a = b 
  a = c
  // 2) 可选参数不兼容固定参数和剩余参数
  b = c
  b = a
  // 3) 剩余参数可以兼容固定参数和可选参数
  c = a
  c = b
}
{
  // 2. 参数类型,参数类型必须要匹配
  let handler3 = (a: string) => {}
  // hof(handler3) // a: string) => void' is not assignable to parameter of type 'Handler'.  

  interface Point3D {
    x: number
    y: number
    z: number
  }
  interface Point2D {
    x: number
    y: number
  }
  let p3d = (point: Point3D) => {}
  let p2d = (point: Point2D) => {}
  p3d = p2d 
  // p2d 不兼容 p3d,成员个数多的会兼容个数少,少的不能兼容成员个数多的,与接口的结论正好相反
  // p2d = p3d
  // Type '(point: Point3D) => void' is not assignable to type '(point: Point2D) => void'.
  // Types of parameters 'point' and 'point' are incompatible.
  // 函数参数相互赋值的情况叫做函数的双向协变,这种情况允许我们把一个精确的类型赋值给一个不那么精确的类型
}
{
  // 3. 返回值类型,ts 要求目标函数的返回值必须与原函数的返回值类型相同,或者是其子类型
  let f = () => ({ name: 'heora' })
  let g = () => ({ name: 'heora', location: 'Beijing' })
  f = g // f 兼容 g
  // g = f // g 不兼容 f,成员少的会兼容成员多的
}

// 函数重载:函数重载分为两部分,一个是函数重载列表,一个是
// 1) 函数重载列表
function overload(a: number, b: number): number
function overload(a: string, b: string): string
// function overload(a: any, b: any, c: any): any{} 
// This overload signature is not compatible with its implementation signature.
function overload(a: any, b: any): any{}

// 枚举兼容性
enum Fruit { Apple, Banana }
enum Color { Red, Yellow }
// 枚举和 number 是可以完全兼容的
let fruit: Fruit.Apple = 3
let no: number = Fruit.Apple
// 枚举之间是完全不兼容的
// let color: Color.Red = Fruit.Apple // Type 'Fruit.Apple' is not assignable to type 'Color.Red'.

// 类兼容性
class A {
  constructor(p: number, q: number) {}
  id: number = 1
}
class B {
  static s = 1
  constructor(p: number) {}
  id: number = 2
}
let a = new A(1, 2)
let b = new B(1)
// 类和接口比较相似,它们也只比较结构
// 比较两个类是否兼容时,静态属性和构造函数是不参与比较的
// 如果两个类具有相同的实例成员,那么它们的实例就可以完全相互兼容
a = b
b = a
// 如果两个类中含有私有成员,那么这两个类就不兼容了,这时只有父类和子类之间是可以相互兼容的
class C {
  constructor(p: number, q: number) {}
  id: number = 1
  private name: string = ''
}
class D {
  static s = 1
  constructor(p: number) {}
  id: number = 2
}
let c = new C(1, 2)
let d = new D(1)
// c = d // Property 'name' is missing in type 'D' but required in type 'C'.
class E extends C {}
let e = new E(1, 2)
c = e
e = c

// 泛型兼容性
interface Empty<T> {
  value: T
}
let obj1: Empty<number> = { value: 1 }
let obj2: Empty<string> = { value: '1' }
// 泛型接口没有任何成员是可以兼容的,当添加成员后就不兼容了
// obj1 = obj2
// obj2 = obj1
// 只有类型参数 T 被接口使用的时候,才会影响泛型的兼容性

// 泛型函数
let log1 = <T>(x: T): T => {
  console.log('x')
  return x
}
let log2 = <U>(x: U): U => {
  console.log('x')
  return x
}
// 如果两个泛型函数的定义相同,没有指定类型参数,它们之间是可以相互兼容的
log1 = log2
log2 = log1
ts

ts 允许我们在类型兼容的变量之间相互赋值,这个特性增加了语言的灵活性。

类型保护

学习类型保护之前,先来看一段代码。

// 类型保护
enum Type { Strong, Week }
class Java {
  helloJava() {
    console.log('hello java')
  }
}
class JavaScript {
  helloJavaScript() {
    console.log('hello javascript')
  }
}
function getLanguage(type: Type) {
  const lang = type === Type.Strong ? new Java() : new JavaScript()
  if ((lang as Java).helloJava) {
    (lang as Java).helloJava()
  } else {
    (lang as JavaScript).helloJavaScript()
  }
  return lang
}
getLanguage(Type.Strong)
ts

相信你已经看出上述代码存在的问题。因为我们不知道程序在运行时到底会传什么样的参数,所以在 getLanguage 方法中必须在使用 lang 时都加上类型断言。这显然不是理想的方案,代码的可读性很差。

类型保护机制就会用来解决这个问题,它可以提前对类型做出预判。

所谓类型保护就是,TypeScript 能够在特定的区块中保证变量属于某种确认的类型。可以在此区块中放心地引用此类型的属性,或调用此类型的方法。

下面我将介绍四种创建这种特定区块的方法。

function getLanguage(type: Type, x?: string | number) {
  const lang = type === Type.Strong ? new Java() : new JavaScript()

  // 不推荐
  if ((lang as Java).helloJava) {
    (lang as Java).helloJava()
  } else {
    (lang as JavaScript).helloJavaScript()
  }
  
  // 1. instanceof 判断实例是否属于某个类
  if (lang instanceof Java) {
    lang.helloJava()
  } else {
    lang.helloJavaScript()
  }

  // 2. in 关键字
  if ('javascript' in lang) {
    lang.helloJavaScript()
  } else {
    lang.helloJava()
  }

  // 3. typeof
  if (typeof x === 'string') {
    x.length
  } else {
    x?.toFixed(2)
  }

  // 4. 通过类型保护函数
  if (isJava(lang)) {
    lang.helloJava()
  } else {
    lang.helloJavaScript()
  }

  return lang
}
// 类型谓词用法
function isJava(lang: Java | JavaScript): lang is Java {
  return (lang as Java ).helloJava !== undefined
}
ts

总结

我们学习了 ts 的类型检查机制,分别是类型推断、类型兼容性、类型保护。利用这些机制,再配合 IDE 的自动补全提示功能能够极大地提高我们的开发效率,需要我们善加利用。

高级类型

所谓高级类型就是 ts 为了保持灵活性所引入的一些元特性。这些特性有助于我们应对复杂多变的开发场景。

交叉类型与联合类型

// 交叉类型和联合类型

// 交叉类型指将多个类型合并为一个类型,新的类型具有所有类型的特性,交叉类型特别适合对象混用的场景
interface DogInterface {
  run(): void
}
interface CatInterface {
  jump(): void
}
// 交叉类型取所有类型的并集
const pet: DogInterface & CatInterface = {
  run() {},
  jump() {}
}

// 联合类型指声明的类型并不确定,可以为多个类型中的一个,
const a: number | string = 1
// 有时候我们不仅需要限制变量类型,还需要限定取值在某个范围内,这时就需要使用字面量类型
const b: 'a' | 'b' | 'c' = 'b'
// 字面量类型不仅可以是字符串,还可以是数字
const c: 1 | 2 | 3 = 3
class Dog implements DogInterface {
  run() { }
  eat() { }
}
class Cat implements CatInterface {
  jump() { }
  eat() { }
}
enum Master { Boy, Girl }
function getPet(master: Master) {
  let pet = master === Master.Boy ? new Dog() : new Cat()
  // 如果一个对象是联合类型,那么在类型未确定的情况下,只能访问所有类型的共有成员
  // 联合类型从字面上来看,是取所有类型的并集,实际上只能取类成员的交集
  pet.eat()

  return pet
}

// 可区分的联合类型
// 这种模式本质上是结合了联合类型和字面量类型的一种类型保护方法
// 核心思想是一个类型如果是多个类型的联合类型,并且每个类型之间存储公共的属性,那么我们就可以凭借这个公共属性,创建类型保护区块
interface Square {
  kind: "square";
  size: number
}
interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}
interface Circle {
  kind: 'circle',
  r: number
}
type Shape = Square | Rectangle
// 这种模式的核心就是利用两种模式的公有属性来创建不同的类型保护区块
function area(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size * s.size
    case "rectangle":
      return s.height * s.width
  }
}
// 如果我们向 Shape 追加一个类型,会发现它并不会报错
// 这时我们可以指定返回值类型对 area2 函数进行约束,这时就必须实现 Circle 的逻辑
type Shape2 = Square | Rectangle | Circle
function area2(s: Shape2): number {
  switch (s.kind) {
    case "square":
      return s.size * s.size
    case "rectangle":
      return s.height * s.width
    case "circle":
      return 2 * Math.PI * s.r
  }
}
ts

交叉类型比较适合做对象的混入,联合类型可以让类型具备一些不确定性,可以增强代码的灵活性。

索引类型

// 索引类型
const obj = {
  a: 1,
  b: 2,
  c: 3
}
function getValues(obj: any, keys: string[]) {
  return keys.map(key => obj[key])
}
console.log(getValues(obj, ['a', 'b'])) // [1, 2]
console.log(getValues(obj, ['e', 'f'])) // [undefined, undefined]

ts

如何使用 ts 对上述模式进行约束?说明具体措施之前,我们先来了解一些概念。

索引类型的查询操作符:keyof T ,表示类型 T 的所有公共属性的字面量的联合类型

interface Obj {
  a: number;
  b: string;
}
let key: keyof Obj
ts

索引访问操作符:T[K],表示对象 T 的属性 K 所代表的类型

let value: Obj['a']
ts

泛型约束:T extends U ,表示泛型变量可以通过继承某个类型获得某些属性

下面我们来改造下 getValues 函数:

function getValues<T, K extends keyof T>(obj: T, keys: K[]): T[K][] {
  return keys.map(key => obj[key])
}
console.log(getValues(obj, ['a', 'b']))
// console.log(getValues(obj, ['e', 'f'])) //  Type '"f"' is not assignable to type '"a" | "b" | "c"'.
ts

索引类型可以实现对对象属性的查询和访问,然后再配合泛型约束就可以建立对象、对象属性以及属性值之间的约束关系。

映射类型

通过映射类型我们可以从一个旧的类型生成一个新的类型,比如说把一个类型的所有属性变为只读。

interface Obj {
  a: string;
  b: number;
  c: boolean;
}
type ReadOnlyObj = Readonly<Obj>
// type ReadOnlyObj = {
//   readonly a: string;
//   readonly b: number;
//   readonly c: boolean;
// }
ts
// Readonly 实现,内置类库

/**
 * Make all properties in T readonly
 */
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

// readonly 是一个泛型接口,而且是一个可索引的泛型接口
// 索引签名是 P in keyof T,T 是一个索引类型的查询操作符,表示类型 T 所有属性的联合类型
// P in 相当于执行了一次遍历,会把变量 P 依次的绑定到 T 的所有属性上
// 索引签名的返回值就是一个索引访问操作符,T[P] 这里代表属性 P 所指定的类型
// 最后再加上 readonly,就可以把所有的属性变成只读,这就是 Readonly 的实现原理
ts
type PartialObj = Partial<Obj>
// type PartialObj = {
//  a?: string | undefined;
//  b?: number | undefined;
//  c?: boolean | undefined;
// }

/**
 * Make all properties in T optional
 */
type Partial<T> = {
    [P in keyof T]?: T[P];
};

// Partial 的实现几乎和 Readonly 一致,只不过把只读的属性变成可选
ts
// Pick 抽取指定属性的子集
type PickObject = Pick<Obj, 'a' | 'b'> 
// type PickObject = {
//   a: string;
//   b: number;
// }

/**
 * From T, pick a set of properties whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};
// 第一个参数 T 指定对象,第二个参数 K 存在一个约束,K 一定要来自 T 所有属性字面量的联合类型
// 新的属性的类型一定要在 K 的属性中选取
ts

以上三种类型,ReadonlyPartialPick,官方有一个称呼,把它们称为同态。含义就是它们不会引入新的属性,只会用到目标类型属性(两个代数结构保持了结构不变的映射,则称这两个代数结构是同态的)。

下面我们再来介绍一种映射类型,它会创建一些新的属性。

type RecordObj = Record<'x' | 'y', Obj>
// type RecordObj = {
//   x: Obj;
//   y: Obj;
// }
ts

映射类型本质上是一种泛型接口,通常会结合索引类型获取对象属性和属性值,从而将一个对象映射成我们想要的结构。

ts 预置了很多映射类型,如果你感兴趣,可以去它的类库中去学习。

条件类型

条件类型是一种由条件表达式所决定的类型。
它的形式是 T extends U ? X : Y 。如果类型 T 可以被赋值给类型 U,结果类型就是 X 类型,否则就是 Y 类型。

条件类型使类型具有了不唯一性,同样也增加了语言的灵活性。

// 条件类型:T extends U ? X : Y
// 条件类型嵌套,依次判断 T 类型,然后返回不同的字符串
type TypeName<T> = 
  T extends string ? 'string' :
  T extends number ? 'number' :
  T extends boolean ? 'boolean' :
  T extends undefined ? 'undefined' :
  T extends Function ? 'function' :
  "object"
type T1 = TypeName<string> // type T1 = "string"  字面量类型 "string"
type T2 = TypeName<string[]> // type T2 = "object"

// 分布式条件类型:(A | B) extends U ? X : Y
// (A extends U ? X : Y) | (B extends U ? X : Y)
type T3 = TypeName<string | string[]> // type T3 = "string" | "object"

// 利用这个特性可以帮助我们实现类型的过滤
type Diff<T, U> = T extends U ? never : T
type T4 = Diff<"a" | "b" | "c", "a" | "e"> // type T4 = "b" | "c"
// => (Diff<"a", "a" | "e">) | (Diff<"b", "a" | "e">) | (Diff<"c", "a" | "e">)
// => never | "b" | "c"
// => "b" | "c"
// 所以 diff 的作用就是从类型 T 中过滤掉可以赋值给类型 U 的类型

// 我们可以从 Diff 再进行扩展,可以从 Diff 中去除掉我们不需要的类型
type NotNull<T> = Diff<T, undefined |  null>
type T5 = NotNull<string | number | undefined | null> // type T5 = string | number

// 我们刚刚实现的两个类型,其实官方也有实现
// Diff => Exclude<T, U>
// NotNull => NotNullable<T>
// 我们实际使用的时候直接使用官方提供的内置类型即可

// 预置的其他条件类型
// Extract<T, U> 作用和 Exclude 相反
// Exclude 是从类型 T 中过掉掉可以赋值给类型 U 的类型
// Extract 是从类型 T 中抽取出可以赋值给类型 U 的类型
type T6 = Exclude<"a" | "c" | "c", "a" | "e"> // type T6 = "c"
// type Extract<T, U> = T extends U ? T : never;

// ReturnType<T>:获取一个函数返回值的类型
type T7 = ReturnType<() => string> // type T7 = string
// type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
// 1. 首先 ReturnType 要求类型 T 可以被赋值给一个函数,函数存在任意的参数和任意的返回值
// 2. 由于返回的返回值是不确定的,所以可以使用 infer 关键字,表示待推断(延迟推断),需要根据实际的情况来确定
// 3. 如果实际的情况返回类型 R,那么结果类型就是 R,否则结果就是 any
js

还有一些类型我们并没有介绍,感兴趣可以自己去 ts 的类库中去看。

技术拓展

type 和 interface 区别?

type 和 interface 多数情况下有相同的功能,就是定义类型。

type:不是创建新的类型,只是为一个给定的类型起一个名字。type还可以进行联合、交叉等操作,引用起来更简洁。
interface:创建新的类型,接口之间还可以继承、声明合并。