Skip to content

一. 联合类型 和 交叉类型

联合类型(Union Type)

什么是联合类型?

由至少两个及以上的类型组成的特殊类型,允许一个变量或参数可以是多个类型中的任意一个。 这种机制为代码提供了极大的灵活性,使得开发者可以避免冗余类型定义,并且在处理多态性的代码时更加灵活和简洁。

在 TypeScript 中,联合类型通过竖线 | 来分隔每个可能的类型。例如,string | number 表示该变量可以是 string 或 number 类型中的任意一个。

示例:

typescript
let foo: string | number
foo = 'hello' // 合法
foo = 42      // 合法

// 非法: Type `boolean` is not assignable to type `string | number`
foo = true

总结

  • 联合类型是由至少两个及以上的类型组成的特殊类型,使用 | 运算符来定义联合类型。
  • 联合类型允许一个变量可以是多个类型中的任意一个。
  • 联合类型提供了灵活的代码结构和更少的重复类型定义。

为什么需要联合类型?

联合类型解决了在某些情况下我们可能需要处理多种类型数据的问题。比如,在处理用户输入时,用户可能输入的是字符串或数字,或者在处理不同的 API 响应数据时,响应的数据可能包含不同的类型。

场景示例:

typescript
function formatId(id: string | number) {
  if (typeof id === 'string') {
    return id.toUpperCase()
  } else {
    return id.toFixed(2)
  }
}

console.log(formatId('abc')) // 输出: ABC
console.log(formatId(123.456)) // 输出: 123.46

总结

  • 联合类型能够简化处理不同数据类型的逻辑。
  • 避免编写冗长的条件检查。

在函数中使用联合类型

函数参数也可以使用联合类型。这意味着传递给函数的参数可以是联合类型的任意一种。

示例:

typescript
function printId(id: number | string): void {
  console.log('ID:', id)
}

printId(101)      // ID: 101
printId('abc123') // ID: abc123

在这个例子中,printId 函数接受一个 number 或 string 类型的参数,并打印它。使用联合类型可以确保函数能够灵活处理不同类型的输入,而不需要分别定义不同的函数。

类型缩小(类型检验)

在联合类型中,我们通常需要根据实际类型执行不同的操作。这时,TypeScript 的类型缩小功能帮助我们根据类型执行不同的逻辑。

使用 typeof 操作符:

typescript
function printId(id: number | string) {
  if (typeof id === 'number') {
    console.log(id.toFixed(2))    // 如果是数字类型,执行数字相关操作
  } else {
    console.log(id.toUpperCase()) // 如果是字符串类型,执行字符串相关操作
  }
}

使用 instanceof 操作符: 如果联合类型中包含了类,可以通过 instanceof 来缩小类型范围。

typescript
class Dog {
  bark() {
    console.log("Woof!")
  }
}

class Cat {
  meow() {
    console.log("Meow!")
  }
}

function speak(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark() // TypeScript 知道 animal 是 Dog 类型
  } else {
    animal.meow() // TypeScript 知道 animal 是 Cat 类型
  }
}

总结

  • 类型缩小能够在联合类型中根据类型动态执行不同的逻辑。
  • typeof 和 instanceof 是常见的类型缩小手段。

联合类型的成员限制

联合类型中的每一个类型被称之为联合成员(union's members),虽然联合类型很强大,但并不是所有类型的成员都能在联合类型中被调用。 你只能调用在所有成员类型中都存在的方法或属性,否则,TypeScript 编译器会抛出编译错误。

示例:

typescript
let value: string | number
value.toString()  // 合法,因为 `toString` 是 string 和 number 的共有方法
value.length      // 不合法,`length` 只存在于 string 类型

为了安全调用不同类型的方法,通常需要先通过类型检查来缩小类型范围,然后再调用对应的方法。

typescript
if (typeof value === 'string') {
  console.log(value.length)  // 安全调用 `length`,因为已经确认是 string 类型
}

总结

  • 在使用联合类型时,不能直接访问非所有成员共有的属性和方法。
  • 需要先通过类型检查来确认当前变量的实际类型。

使用联合类型的最佳实践

  • 类型缩小:在使用联合类型时,通常会涉及类型缩小,通过 typeof 或 instanceof 操作符进行检查,从而执行不同的逻辑。
  • 避免滥用:虽然联合类型能够让代码更加灵活,但不应过度依赖。如果类型组合过多,可能会增加代码的复杂性。
  • 通用接口:如果多个类型有相同的属性或方法,考虑提取为接口,减少冗余。
typescript
interface Bird {
  fly(): void
}

interface Fish {
  swim(): void
}

function move(animal: Bird | Fish) {
  if ('fly' in animal) {
    animal.fly()
  } else {
    animal.swim()
  }
}

🔔 提示

  • 在设计复杂的类型时,合理使用接口来避免重复。
  • 通过合理的类型缩小确保代码的正确性和可维护性。

交叉类型(Intersection Types)

什么是交叉类型?

在 TypeScript 中,我们前面学过联合类型,它表示变量可以是多个类型中的一个:

联合类型允许一个变量是多种类型中的任意一种,使用 |(竖线)符号定义联合类型。

示例:

typescript
type Alignment = 'left' | 'center' | 'right'

除了联合类型,还有一种称为交叉类型的合并方式:

交叉类型表示一个变量需要同时满足多个类型的条件,使用 &(和)符号定义交叉类型。

示例:

typescript
type MyType = number & string // 无意义,等同于 never 类型

在这个例子中,MyType 要求同时满足 number 和 string 类型。但因为一个值不可能既是 number 又是 string,因此这个类型实际上是 never(永远不可能存在的类型)。

交叉类型的实际应用

在实际开发中,交叉类型通常用于对象类型的组合,让一个对象包含多个类型的属性或方法。

示例:

typescript
interface IPerson {
  name: string
  age: number
}

interface IKun {
  dance: () => void
}

const obj: IPerson & IKun = {
  name: 'later',
  age: 18,
  dance: () => console.log('dancing')
}

在上面的例子中:

  • obj 必须同时满足 IPerson 和 IKun 两个接口的定义。
  • 也就是说,obj 既需要拥有 name 和 age 属性,又需要实现 dance 方法。

这种方式可以方便地将多个接口组合在一起,用于描述一个更复杂的对象。

二. type 和 interface 的使用

类型别名 - type

在 TypeScript 中,我们常常需要定义类型注解来确保变量的类型正确。例如对象类型或联合类型的注解。但如果这种注解在多个地方使用,重复写会增加代码量且不易维护。

类型别名的定义

为了解决这个问题,可以使用 type 类型别名来为某些类型起一个名字,方便复用。

typescript
type Point = {
  x: number
  y: number
}

function printPoint(point: Point) {
  console.log(point.x, point.y)
}
function sumPoint(point: Point) {
  console.log(point.x + point.y)
}
printPoint({x: 20, y: 30})
sumPoint({x: 20, y: 30})

在上面的代码中,Point 是我们定义的一个类型别名,表示一个包含 x 和 y 坐标的对象类型。

联合类型也可以用 type 定义:

typescript
type ID = number | string

function printId(id: ID) {
  console.log('id: ', id)
}

什么时候使用 type

通常来说: 如果要定义一个非对象类型,如基本数据类型(string、number)、联合类型(|)、函数类型等,建议使用 type。

接口声明 - interface

使用 interface 声明对象类型

在定义对象类型时,除了 type,TypeScript 还提供了另一种方式,即接口(interface)。

typescript
interface Point {
  x: number
  y: number
}

与 type 相似,interface 也可以定义对象类型:

typescript
function printPoint(point: Point) {
  console.log(point.x, point.y)
}

为什么选择 interface

在定义对象类型时,type 和 interface 大部分情况可以互换使用。
但在实际开发中,两者在某些方面存在区别,我们可以根据这些区别来选择使用哪一种。

type 和 interface 的区别

在 TypeScript 中,type 和 interface 都可以用于定义对象类型。那什么时候该用 type,什么时候该用 interface 呢?

type 的使用范围更广

type 不仅可以定义对象类型,还可以用来定义基本类型、联合类型、交叉类型等,使用范围更广,而 interface 类型只能用来声明对象。

typescript
type MyNumber = number
type ID = number | string
type PersonType = { name: string; age: number }

interface 可以重复声明

接口可以通过多次声明扩展属性,而 type 不能重复定义。

示例:

typescript
interface IPerson {
  name: string
  running: () => void
}

interface IPerson {
  age: number
}

type Person = {}
type Person = {} // Error: 重复标识符'Person'

上述代码中,我们将 IPerson 接口分多次定义。TypeScript 会将它们自动合并到一个完整的接口。 但 type 类型别名不能重复定义,否则会报错。

interface 支持继承(扩展)

接口支持继承其他接口,从而实现扩展,而 type 不直接支持继承。

typescript
interface IPerson {
  name: string
  age: number
}

interface IKun extends IPerson {
  slogan: string
}

const ikun: IKun = {
  name: 'Kun',
  age: 18,
  slogan: '你干嘛!哎哟'
}

type TKun = { slogan: string } & IPerson

在上面的例子中,IKun 接口继承了 IPerson 接口,这样就形成了包含 name、age、slogan 的复合接口。 类型别名 TKun 通过交叉类型的方式组合了 IPerson 和 { slogan: string },实现了相同的功能。

好处

  • 减少重复代码:不同接口继承同一个基础接口可以复用属性。
  • 代码一致性:尤其在使用第三方库时,通过继承接口可确保新接口也符合库中接口的标准。
  • 继承后属性检查:在实现继承接口的对象时,TypeScript 会严格检查所有必需属性。若缺少属性,编译会报错,确保类型安全。

interface 可以被类实现

接口不仅可以继承,还可以被类实现(implements),使类符合接口的定义,方便统一处理接口和类之间的关系。这种方式也称为面向接口编程,即在代码中通过接口来定义结构、行为,而不是直接依赖具体类。

typescript
interface IKun {
  name: string
  slogan: string
  dance: () => void
}

interface IRun {
  running: () => void
}

const ikun: IKun = {
  name: 'later',
  slogan: '你干嘛',
  dance: function() {}
}


class Person implements IKun, IRun {
  constructor(
    public name: string, 
    public slogan: string, 
    public dance: () => void, 
    public running: () => void
  ) {}
}

const ikun1 = new Person('later', '小黑子', function(){}, function(){})
console.log(ikun1.name, ikun1.slogan, ikun1.dance, ikun1.running)

在这个例子中,Person 类实现了 IKun 和 IRun 接口,确保类中定义了 IKun 和 IRun 所需的所有属性和方法。这样可以统一接口的定义和具体类的实现逻辑。

面向接口编程的好处

  • 灵活性:可以根据接口定义替换不同实现,代码更灵活。
  • 模块化:接口定义清晰,使得不同模块之间依赖关系更清晰。

总结

比较点typeinterface
定义范围对象、基本类型、联合、交叉等各种类型只能定义对象类型
重复定义不支持允许多次定义,会自动合并为单个接口
继承不支持,可通过交叉类型(&)模拟支持继承,使用 extends 实现继承
类实现不支持支持,使用 implements 实现

对于简单类型,建议使用 type。当需要定义对象类型时,优先使用 interface。

三. 类型断言 和 非空断言

在 TypeScript 中,有时需要告诉编译器变量的类型,或表明某个值一定存在,即使编译器推测可能为空。为此,TypeScript 提供了类型断言和非空断言。

类型断言 as

类型断言用于在 TypeScript 推测不出具体类型时,手动指定变量类型。举个例子:

假设使用 document.getElementById 获取一个页面元素。由于 getElementById 的返回值类型为 HTMLElement | null,TypeScript 并不清楚它的确切元素类型:

typescript
// TS 编译器会推断为 HTMLElement | null
const myEl = document.getElementById('my-img')

myEl 的类型被推断为 HTMLElement | null。因此,如果我们直接操作它的 src 或 alt 属性,会报错,因为 HTMLElement 类型不具备这些属性:

typescript
const myEl = document.getElementById('my-img')
myEl.src = 'https://picsum.photos/200/300'// Error: 对象可能是 'null'
myEl.alt = 'A cool image'// Error: 对象可能是 'null'

// 即使我们使用缩小类型相关的操作,来访问某些属性时也会报错
if (myEl !== null) {
  myEl.src = 'https://picsum.photos/200/300'// Error: 类型 'HTMLElement' 上不存在属性 'src'
  myEl.alt = 'A cool image'// Error: 类型 'HTMLElement' 上不存在属性 'alt'
}

如果我们确定 myEl 是一个图片元素,这时,就可以将它断言为 HTMLImageElement 类型,之前的操作也就不会引起报错了:

typescript
const myEl = document.getElementById('my-img') as HTMLImageElement
myEl.src = 'https://picsum.photos/200/300'
myEl.alt = 'A cool image'

if (myEl !== null) {
  myEl.src = 'https://picsum.photos/200/300'
  myEl.alt = 'A cool image'
}

⚠️ 注意

使用 as 强制声明类型后,TypeScript 将不再检查这个元素的具体类型,直接按照断言类型操作属性。

限制:类型断言通常用于将类型转换为更具体的类型(如 HTMLElement 到 HTMLImageElement),或更宽泛的类型(如 HTMLElement 到 any 或 unknown)。
但无法将完全无关的类型进行断言,例如:

typescript
const age: number = 18

const ageString = age as string// 报错:类型'number'无法直接断言为'string'

可以先断言为 any 或 unknown,再进行二次断言,但这样做风险较高,实际开发中不建议这么操作。

typescript
const ageString = age as any as string

非空类型断言 !

TypeScript 默认会检查标识符是否可能为 null 或 undefined。例如,传入一个可选参数时:

typescript
function printMessage(message?: string) {
  console.log(message.toUpperCase()) // 报错:Object is possibly 'undefined'
}

printMessage('hello')

这是因为 message 可能是 undefined,而 message.toUpperCase() 方法要求 message 一定有值。 为解决此问题,可以选择:

  1. 使用可选链操作符 ?. 确保安全访问:
typescript
function printMessage(message?: string) {
  console.log(message?.toUpperCase())
}
  1. 当我们确信 message 一定有值时,可以使用非空断言 ! 来跳过 TypeScript 的 null 检查:
typescript
function printMessage(message?: string) {
  console.log(message!.toUpperCase())
}

⚠️ 注意

非空断言仅适合在确保变量有值的情况下使用,否则运行时可能导致错误。

特殊场景:属性赋值中的非空断言

假设我们有一个嵌套的对象类型,且某个可选属性在赋值前必须确保存在:

typescript
interface IPerson {
  name: string
  friend?: {
    name: string
  }
}

const info: IPerson = {
  name: "Alex",
}

如果尝试直接使用可选链赋值会报错:

typescript
info?.friend?.name = 'Mike'// 错误:左侧的可选属性不支持直接赋值

解决办法

  1. 类型缩小:检查属性是否存在再赋值。
typescript
if (info.friend) {
  info.friend.name = "Mike"
}
  1. 非空断言:如果确定 info.friend 一定存在,可以直接使用非空断言。
typescript
info.friend!.name = "Mike"

在不确定变量是否有值的场景,应避免非空断言,推荐先通过类型检查,确保安全后再使用。

四. 字面量类型 和 类型缩小

字面量类型(literal types)

字面量类型是指在 TypeScript 中不仅可以指定一个类型(如 string、number),还可以直接指定某个“具体值”作为类型。例如:

typescript
let message: 'hello' = 'hello'
message = '你好啊' 
// 报错:Type '你好啊' is not assignable to type 'hello'

在上面的代码中,message 的类型被限制为固定的值 "hello",所以不能赋其他字符串。

实际应用: 字面量类型通常和“联合类型”结合使用,为变量定义多个可能的值,从而限制可用的值。例如,可以将 Alignment 类型限制为三个方向选项:

typescript
type Alignment = 'left' | 'center' | 'right'

function changeAlign(align: Alignment) {
  console.log('修改方向:', align)
}

changeAlign('left') // 正确
changeAlign('top') // 错误: 类型 '"top"' 不能分配给类型 'Alignment'

这样可以确保只能传入 'left'、'center' 或 'right',从而避免传入不支持的值。

字面量推理

在 TypeScript 中,当你定义一个对象时,TypeScript 会尝试推断其属性的类型。

例如:

typescript
const info = {
  url: 'aaa/bbb/ccc',
  method: 'GET'
}

TypeScript 会将 info 推断为 { url: string, method: string },而不是 { url: 'aaa/bbb/ccc', method: 'GET' } 的字面量类型。所以在调用下面的函数时会报错:

typescript
function request(url: string, method: 'GET' | 'POST') {
  console.log(url, method)
}

request(info.url, info.method)  // 报错:类型'string'的参数不能赋值给类型'GET' | 'POST'的参数

解决办法:

  • 类型断言:直接断言 info.method 是字面量类型 'GET'。
    typescript
    request(info.url, info.method as 'GET')
  • 使用 as const:强制将整个 info 对象的属性推断为字面量类型。
    typescript
    // 这样显式写会比较麻烦,如果对象属性比较多呢
    const info: {url: string, method: 'GET' | 'POST'} = {
      url: 'aaa/bbb/ccc',
      method: 'GET'
    }
    
    // 特别语法 as const 强制变为一个字面量类型
    const info = {
      url: 'aaa/bbb/ccc',
      method: 'GET'
    } as const

类型缩小

类型缩小(Type Narrowing)是指在某些条件下,TypeScript 能进一步“收窄”变量的类型范围。例如,使用 typeof 或条件语句时,TypeScript 会自动推断类型并缩小变量可能的类型范围。

我们可以通过类似于 typeof padding === "number" 的判断语句,来改变 TypeScript 的执行路径。 在给定的执行路径中,我们可以缩小成比声明时更小的类型,这个过程称之为类型缩小。

常见的类型缩小有如下几种:

typeof、平等缩小(如 ===、!==)、instanceof、in、...

typeof

typeof 操作符可以用来检查变量类型,TypeScript 会自动识别类型:

typescript
type ID = number | string

function printId(id: ID) {
  if (typeof id === 'string') {
    console.log(id.toUpperCase()) // TS 编译器会确认 id 为 string 类型
  } else {
    console.log(id) // TS 编译器会确认 id 为 number 类型
  }
}

平等缩小

使用相等运算符(如 ===、!==)来检查值的具体类型时,TypeScript 也会自动缩小类型范围:

typescript
type Direction = 'left' | 'center' | 'right'

function turnDirection(direction: Direction) {
  if (direction === 'left') {
    console.log("向左")
  } else if (direction === 'center') {
    console.log("向中")
  } else {
    console.log("向右")
  }
}
也可以使用 switch 语句来表达相等性
typescript
type Direction = 'left' | 'center' | 'right'

function turnDirection(direction: Direction) {
  switch(direction) {
    case 'left': 
      // ...
      break
    case 'center':
      // ...
      break
    case 'right':
      // ...
      break
    default:
      // ...
  }
}

instanceof

instanceof 操作符可以检查某个对象是否是某个类的实例:

typescript
function printValue(value: Date | string) {
  if (value instanceof Date) {
    console.log(value.toLocaleDateString()) // 确定为 Date 类型
  } else {
    console.log(value) // 确定为 string 类型
  }
}

in 操作符

in 操作符,用于判断对象是否具有某个属性,可以根据该属性是否存在来确定对象类型:

如果指定的属性在指定的对象或其原型链中,则 in 操作符返回 true。

typescript
type Fish = {swim: () => void}
type Dog = {run: () => void}

function move(animal: Fish | Dog) {
  if (animal.swim) // 报错: 类型'Dog'上不存在'swim'属性
  if ('swim' in animal) { // 这里要用 key,不能直接使用 swim,会被当成标识符
    animal.swim()
  } else {
    animal.run()
  }
}

五. 函数类型 和 函数签名

在 JavaScript 中,函数是“一等公民”,即它们可以像变量一样传递、作为参数传递或作为返回值。然而在 TypeScript 中,我们不仅可以定义函数,还可以为函数设置类型!

函数类型表达式

在 TypeScript 中,我们可以编写函数类型的表达式(Function Type Expressions),来表示函数类型

typescript
// 格式: (参数列表) => 返回值
type CalcFunc = (num1: number, num2: number) => void

function calc(fn: CalcFunc) {
  fn(20, 30)
}

function sum(num1: number, num2: number) {}
calc(sum)

这里 (num1: number, num2: number) => void 表示一个函数类型:

  • 接受两个 number 类型参数 num1 和 num2
  • 没有返回值,所以返回类型是 void

注意:函数类型表达式中的参数名称(如 num1 和 num2)不可省略。

函数参数的个数检验和兼容性

要点概述

函数直接调用时:传入的参数数量需要匹配函数的定义。

函数作为参数传递时:允许该函数的实参数量少于参数类型定义时函数的形参数量,但不允许多余的参数。

解释原因

在 TypeScript 中,参数少的函数兼容参数多的函数。这种兼容性规则称为“函数参数的协变与逆变”。 主要原因在于 TS 希望确保调用时安全性,也就是说,当一个函数被要求接收更多参数时,我们传入一个接收更少参数的函数仍然是安全的(因为剩余的参数不会被用到)。

具体规则如下:

  1. 少参数的函数可以兼容多参数函数:TypeScript 允许我们传入参数更少的函数类型。这种设计使得函数调用更具灵活性,因为传入的函数并不需要严格符合所有参数的需求。
  2. 多参数的函数不兼容少参数函数:当传入参数多于定义时,TypeScript 会认为它可能引入不必要的依赖或意外行为,因此会报错。

示例:

typescript
type CalcType = (n1: number, n2: number) => number

function calc(calcFn: CalcType) {
  return calcFn(10, 20) // 函数直接调用时,必须传入两个参数
}

// 函数作为参数传递时
const fn1 = (n1: number) => 42 // 参数少于定义的函数
calc(fn1) // ✅ 不报错,因为少参数的函数可以兼容多参数的函数类型

const fn2 = (n1: number, n2: number, n3: number) => 42 // 参数多于定义的函数
calc(fn2) // ❌ 报错,参数数量超过定义

再来看一个例子:

typescript
// 如果我们传入的函数类型的参数必须符合要求的话,那每次使用这些高阶函数时,难道都需要写一堆不需要的参数吗?
// forEach 栗子:
const names = ["abc", "cba", "nba"]
names.forEach(function (item) {
  console.log(item.length) // ✅ TS编译器允许
})

这里不需要将 index 和 array 参数都列出来,因为高阶函数 forEach 在定义上已经允许少参数的回调函数。

原因:TypeScript 允许传入的函数参数数量少于定义的数量。这是因为 TS 的类型兼容性遵循结构化子类型(结构兼容性)原则,即少参数的函数可以兼容多参数的函数。在高阶函数(如 forEach)的回调中,我们传入的函数可以忽略不需要的参数。

函数调用签名(Call Signatures)

在 JavaScript 中,函数本身也是一种对象,也是可以具备属性的,所以也可以用对象类型来描述函数。 然而前面讲到的函数类型表达式,并不能支持声明函数对象的属性。 如果我们想描述一个带属性的函数对象,可以在一个对象类型中写一个调用签名来定义它。

typescript
// 1.函数类型表达式(不支持描述带属性的函数)
type BarType = (num1: number) => number

// 2.函数调用签名(函数本身就是对象,所以用可以当成对象类型来描述)
interface IBar {
  name: string
  age: number
  // 与函数类型表达式不同,调用签名中的参数和返回类型用冒号 `:` 连接,而不是箭头 `=>`
  (num1: number): number// 调用签名
}

const bar: IBar = (num1: number): number => 123 // 在不同的编辑器中,可能这句会有报错
// 如:在 vscode 中不会报错,但在 webstorm 中有报错
// 分析:不同编辑器中内置的 TypeScript 服务检测策略会有差异
// 可以以TS Playground上的测试结果为标准(https://www.typescriptlang.org/play)
// 如果报错,代码层面解决:使用类型断言,如下:
const bar: IBar = ((num1: number) => 123) as IBar

bar.name = "aaa"
bar.age = 18
bar(123)

开发中如何选择:

  • 函数类型表达式适合描述仅表示调用的函数类型
  • 调用签名适合描述有属性的函数对象

函数构造签名(Construct Signatures)

在 TypeScript 中,当函数用 new 操作符调用时,它被认为是一个构造函数,会创建一个新对象。可以通过构造签名来表示这种构造函数类型:

  • 你可以写一个构造签名,方法是在调用签名前加一个 new 关键词
    typescript
    interface IPerson {
      (name: string): void // 调用签名,之后 new 出来的对象是 any 类型
      new (name: string): Person // 构造签名,之后 new 出来的对象,TS 编译器知道是 Person 类型
    }
    
    function factory(ctor: IPerson) {
      return new ctor('later')
    }
    
    class Person {
      constructor(name: string) {
        this.name = name
      }
    }
    
    factory(Person)

参数的可选类型

通过在参数名后加 ? 表示该参数是可选的。可选参数的类型是 参数类型 | undefined,并且必须放在必选参数后。

typescript
function foo(x: number, y?: number) {
  console.log(x, y)
}

foo(10)  // 可以只传一个参数

默认参数

在 TypeScript 中,也是支持参数设置默认值。此时参数类型会自动变为 参数类型 | undefined

typescript
function foo(x: number, y: number = 20) {
  console.log(x, y)
}

foo(10)

有默认值的时候,可以不写类型注解,且可以接收一个 undefined 的值。

typescript
function foo(x: number, y = 20) {
  console.log(x, y)
}

foo(10, undefined)

这个时候 y 的类型其实是 undefined | number 类型的联合。

剩余参数

从 ES6 开始,剩余参数语法 ...args 允许我们将不定数量的多个参数放到一个数组中:

typescript
function sum(...nums: number[]) {
  console.log(nums)
}
sum(10, 20, 30) // [10, 20, 30]

// 剩余参数也支持联合类型:
function bar(...args: (string | number)[]) {
  console.log(args)
}
sum(10, 20, 30) // [10, 20, 30]
sum('aaa', 'bbb', 'ccc') // ['aaa', 'bbb', 'ccc']

六. 函数的重载 和 this 类型

函数的重载

在 TypeScript 中,有时我们希望一个函数可以支持不同类型的参数,比如一个 sum 函数,既能相加数字,也能连接字符串。来看一个例子:

typescript
function sum(a1: number | string, a2: number | string): number | string {
  return a1 + a2// 报错:运算符'+'不能应用于'string | number' 和 'string | number'类型
  // 在不同的编辑器中,可能这句也不会有报错
  // 如:在 vscode 中会报错,但在 webstorm 中不会报错
  // 分析:不同编辑器中内置的 TypeScript 服务检测策略会有差异
  // 可以以TS Playground上的测试结果为标准(https://www.typescriptlang.org/play)
}

sum(10, 20)
sum('aaa', 'bbb')

直接使用联合类型会报错。TypeScript 提供了函数重载来处理这种情况,通过定义多个重载签名,再实现一个通用函数体:

在 TS 中,我们可以编写不同的重载签名来表示函数可以以不同的方式进行调用
一般是编写两个或以上的重载签名,再去编写一个通用的函数以及其实现

比如我们对 sum 函数进行重构:

在我们调用 sum 的时候,它会根据传入的参数类型来决定执行函数体时,到底执行哪一个函数的重载签名

typescript
// TypeScript中函数的重载写法

// 1.先编写重载签名
function sum(arg1: number, arg2: number): number
function sum(arg1: string, arg2: string): string

// 2.编写通用函数的实现
function sum(arg1: any, arg2: any): any {
  return arg1 + arg2
}

sum(10, 20)       // 输出 30 
sum("aaa", "bbb") // 输出 "aaabbb"

但是注意,只有实现函数体的通用函数不允许直接调用,只能调用符合重载签名的参数类型。例如:

typescript
// 通用函数不能被调用
sum({name: "why"}, "aaa")  // 报错:没有与此调用调用匹配的重载
sum("aaa", 111)            // 报错:没有与此调用调用匹配的重载

联合类型和重载的选择

现在有一个需求:定义一个函数,可以传入字符串或数组,获取它们的长度。

  • 方案一:使用联合类型
    typescript
    function getLength(arg: string | any[]) {
      return arg.length
    }
  • 方案二:使用函数重载
    typescript
    function getLength(a: string): number;
    function getLength(a: any[]): number;
    function getLength(arg: any) {
      return arg.length
    }

在开发中我们选择使用哪一种呢?

通常优先使用联合类型,因为联合类型实现较为简洁,代码更具可读性。

可推导的 this 类型

this 是 JS 中一个比较难以理解和把握的知识点:

coderwhy 公众号也有一篇文章专门讲解 this:前端面试之彻底搞懂this指向

当然在目前的 Vue3 和 React 开发中你不一定会使用到 this:

Vue3 的 Composition API 中很少见到 this,React 的 Hooks 开发中也很少见到 this 了。

但是我们还是简单掌握一些 TS 中的 this,TS 是如何处理 this 呢? 我们先来看两个例子:

typescript
const obj = {
  name: 'obj',
  foo: () => console.log(this.name) // 注意这里是箭头函数
}
obj.foo()

function foo1() {
  console.log(this)
}
foo1()

默认情况下,上面的代码是可以正常运行的,因为默认情况下,TypeScript 会将未指定的 this 视为 any 类型,不会在编译时报错。

this 的编译选项

在 TypeScript 中,可以在项目根目录创建 tsconfig.json 配置文件并初始化配置:

bash
# 初始化 TypeScript 配置文件
tsc --init

然后可以启用 noImplicitThis 配置项来确保 this 必须有明确的类型。将 noImplicitThis 设置为 true(tsconfig.json 文件中该选项默认就是开启的)。

这样,TypeScript 会根据上下文推导 this 类型,并在无法推导时报错:

typescript
function foo1() {
  console.log(this) // Error:'this'隐式具有'any'类型. 因为它没有类型注释
}

foo1()

⚠️ 注意

tsconfig.json 配置文件中的编译配置选项仅对当前目录下的所有 .ts 文件起作用。

指定 this 的类型

在开启 noImplicitThis 的情况下,必须指定 this 的类型。

如何指定呢?:

可以根据该函数之后被调用的情况,函数参数列表中的第一个参数用于声明 this 的类型(名词必须叫 this)。
后续调用函数传入参数时,从第二个参数开始传递的,this 参数会在编译后被抹除。

typescript
function foo1(this: {name: string}, age: number) {
  console.log(this)
}

foo1() // 类型为“void”的 "this" 上下文不能分配给类型为“{ name: string; }”的方法的 "this"
foo1.call({name: 'hehe'}, 18)

在此例中,this 的类型是 { name: string },因此调用时 this 必须具有相应的属性。

this 相关的内置工具类型

TypeScript 提供了一些内置工具类型,用于帮助处理 this 类型,以下是常用的几种:

  • ThisParameterType:用于提取函数类型的 this 参数类型,如果函数类型没有 this 参数则返回 unknown。

    适用于需要从已有函数中提取 this 参数类型的场景,比如在编写一些通用工具函数时,需要知道函数的 this 参数类型。

    typescript
    function foo(this: { name: string }, info: {name: string}) {
      console.log(this, info)
    }
    
    // 获取 foo 函数的类型
    type FooType = typeof foo
    
    // 获取 FooType 类型中 this 的类型
    type FooThisType = ThisParameterType<FooType> // { name: string }

    场景:提取函数中的 this 参数类型

    当需要从某个函数类型中提取 this 参数类型时,可以使用 ThisParameterType。这种提取在编写需要对 this 进行操作的泛型函数或库函数时非常有用。
    例如:假设我们有一个类,其中的方法使用了 this 参数指向类实例。我们希望编写一个通用的日志函数,用于输出 this 指向的内容:

    typescript
    class User {
      name: string
      constructor(name: string) {
        this.name = name
      }
    
      logName(this: User) {
        console.log("User name:", this.name)
      }
    }
    
    type LogNameThisType = ThisParameterType<typeof User.prototype.logName> 
    // 提取出 { name: string }

    这里,我们通过 ThisParameterType 提取了 logName 方法的 this 参数类型,这样我们可以在其他地方复用 this 类型,比如构建其他工具函数或进行类型检查。

    • 创建通用的函数类型:在某些库中定义“带有this参数的泛型函数”,便于在不同的上下文中灵活使用。
    • 运行时检查或日志:在调用函数时,通过提取this类型,可以在不同的上下文下对该类型的属性进行检查或调试。
  • OmitThisParameter:用于移除函数类型中的 this 参数,并返回不包含 this 的函数类型。

    typescript
    function foo(this: { name: string }, info: {name: string}) {
      console.log(this, info)
    }
    
    // 获取 foo 函数的类型
    type FooType = typeof foo
    
    // 返回删除 this 参数类型后的函数类型
    type PureFooType = OmitThisParameter<FooType>

    场景:移除 this 参数以便在回调中使用

    假设我们有一个带 this 参数的函数 sayHello,我们希望在不绑定 this 的情况下,将它作为普通函数使用:

    typescript
    class Greeter {
      language = "English"
      sayHello(this: Greeter, name: string) {
        return `Hello ${name}, welcome to ${this.language}`
      }
    }
    
    // 使用 OmitThisParameter 移除 this 参数,转换成一个没有 this 的函数类型
    type SayHelloNoThis = OmitThisParameter<typeof Greeter.prototype.sayHello>
    
    const greeter = new Greeter()
    const greetFunction: SayHelloNoThis = greeter.sayHello.bind({ language: "French" })
    
    // 现在可以不依赖 this 使用 sayHello
    console.log(greetFunction("Alice")) // Output: Hello Alice, welcome to undefined

    在这里,OmitThisParameter 去掉了 this 参数,因此可以将 sayHello 作为无 this 的普通函数传递和调用。

    • 异步操作中的回调函数:有些情况下this会指向undefined,移除this参数可以避免无效的this访问。
    • 事件处理程序:如果想将含有this参数的函数作为通用函数直接传递到其他组件或模块,删除this可以简化调用。
  • ThisType:用于绑定一个上下文中的 this 类型,常用于对象上下文中。如:

    事实上官方文档的不管是解释,还是案例都没有说明出来 ThisType 类型的作用。

    typescript
    interface IState {
      name: string
      age: number
    }
    
    interface IStore {
      state: IState
      eating: () => void
      running: () => void
    }
    
    // 2.所以我们使用ThisType<IState>: 让store中的this类型都是IState类型
    const store: IStore & ThisType<IState> = {
      state: {
        name: "why",
        age: 18
      },
      // 1.下面这样写也可以,但是实际开发中有很多方法,每一个都写会很麻烦
      // eating: function(this: IState) { console.log(this.name) },
      // running: function(this: IState) { console.log(this.name) }
      eating: function() { console.log(this.name) }, // 在这里 this 类型会被推导为 IState
      running: function() { console.log(this.name) }
    }
    
    store.eating.call(store.state)

    在这个例子中,ThisType 将 this 的类型设定为 { name: string; age: number },因此 eating 和 running 方法中的 this 都能直接访问 name。这在使用字面量对象创建状态管理时尤其方便,不需要额外使用类。

    • 上下文管理:在大型应用或类库中,使用 ThisType 绑定 this 类型,便于访问对象上下文属性。
    • 复杂对象设计:当设计复杂的配置对象或上下文对象时,可以使用 ThisType 明确指定对象上下文中的 this 类型。

七. "新鲜性"检查

TypeScript 对对象的直接定义和赋值也有不同的检查规则,通常称为新鲜性检查: 在直接赋值的初次定义时严格要求对象的结构符合接口;在已定义对象的二次赋值时,允许多余属性的存在。 这一检查用来确保初次声明时对象不会包含超出接口定义的属性

示例:

typescript
interface IPerson {
  name: string
  age: number
}

// 情况一:直接赋值到接口类型,进行严格检查(这种会检测,因为是第一次定义的,就会检测)
const info: IPerson = {  
  name: "why",
  age: 18,
  height: 1.88, // ❌ 报错:IPerson 类型上不存在 height 属性
}

// 情况二:使用一个变量(非新鲜对象)赋值
const p = {
  name: "why",
  age: 18,
  height: 1.88,
}
// 这种不会检测,因为p不是第一次定义的
const info2: IPerson = p // ✅ 不报错,TypeScript 允许非新鲜对象有额外属性

TypeScript github issue 成员: 这种现象是否会检测,取决于赋值的对象是否是新鲜的(即第一次定义),第一次定义的,就会检测。 最早的时候,不是这样的,是后续 ts 版本更新的时候增加的规则。
TS对于很多类型的检测报不报错, 取决于它的内部规则。
TS版本在不断更新: 在进行合理的类型检测的情况, 让ts同时更好用(好用和类型检测之间找到一个平衡)。

为什么存在“新鲜性”检查?

TypeScript 为了确保初次声明时类型安全,在直接赋值的情况下会严格检查对象是否符合接口(即不包含多余属性)。如果对象已经在之前定义过(如 p 变量),TypeScript 会认为该对象结构已被“确认”,因此允许包含额外属性。

TypeScript 在类型检查上这种设计在实际开发中既确保了类型的安全性,又保持了一定的灵活性。

Released under the MIT License.