前言

现代化大型软件开发,静态类型语言已经是主流共识了。

项目越复杂、人越多,人脑就越难掌控全局。这时候,编程语言的类型系统加上 IDE 的辅助就显得格外重要。

前端领域这几年发展迅猛,JavaScript 项目也变得越来越复杂。开发和维护都越来越专业化、越来越有挑战性。好在有了 TypeScript——它强大的类型系统让复杂项目的开发变得容易多了。

本文想系统性地梳理下 TypeScript 的类型系统,帮我们温故知新,查漏补缺。


TypeScript 类型基础回顾

在深入复杂类型之前,我们先快速过一遍 TypeScript 的基础类型系统,就像打比赛前先热个身。

// 原始类型 - 最基础的积木块
const str: string = 'hello'
const num: number = 42
const bool: boolean = true
const sym: symbol = Symbol('foo')
const big: bigint = 100n
// 特殊类型 - 几个"怪咖"
const nullValue: null = null
const undef: undefined = undefined
const voidFunc: () => void = () => {}
// 数组和元组 - 一组数据的容器
const arr: string[] = ['a', 'b'] // 数组:全是字符串
const tuple: [string, number] = ['hello', 42] // 元组:固定位置、固定类型
// 联合类型 - "要么这个,要么那个"
const mixed: (string | number)[] = [123, 'str']
// 对象类型 - 描述事物的形状
// 用 interface 描述
interface User {
name: string
age: number
}
// 或用 type 别名
type User2 = {
name: string
age: number
}
// 加点修饰符:只读、可选
interface AdvancedUser {
readonly id: number // 只读:一旦定了就不能改
name: string
age?: number // 可选:有也行,没有也行
}
// 函数类型 - 描述函数的输入输出
// 函数声明
function add(x: number, y: number): number {
return x + y
}
// 函数表达式
const multiply: (x: number, y: number) => number = (x, y) => x * y
// 可选参数和默认参数
function greet(name: string, greeting?: string): string {
return greeting ? `${greeting}, ${name}` : `Hello, ${name}`
}

好了,基础类型我们算是过了一遍。但这些只是静态的类型标注,写代码时类型往往会变得更复杂——比如一个变量既可能是 string 又可能是 number,这时候怎么办?

这就得用到 TypeScript 的一个核心机制了:Narrowing(类型收窄)


Narrowing

Narrowing(类型收窄)就是 TypeScript 知道某些代码块里变量的类型更具体了。听着有点抽象?看个例子就懂了。

function process(value: string | number) {
// 在这里,value 既可能是 string 也可能是 number
// TypeScript 不确定它到底是啥,所以不敢让你调用专属方法
// 如果直接调用 string 的方法,会报错
// value.toUpperCase() // 错误!value 可能是 number
// 所以得先"确认"一下 value 的类型
if (typeof value === 'string') {
// TypeScript 心里就有数了:既然进了这个分支,value 肯定是 string
// 所以这里可以放心调用 string 的方法
return value.toUpperCase()
} else {
// 通过控制流分析,到这里 value 必定是 number
return value.toFixed(2)
}
}

就像个安检门:TypeScript 检查你的条件,知道”哦,进了这个分支的一定是 string”,然后就放心让你用 string 的方法了。

这种类型收窄机制让写代码既安全又顺手,我们来看看都有哪些收窄手段。

typeof 类型守护

正如开头例子演示的,typeof 是最基础的收窄手段。它能很好地把一个联合类型缩窄到具体的类型,特别适合处理基本类型的联合。

instanceof 类型守护

typeof 处理基本类型,instanceof 处理对象类型——尤其是类。

看个例子:

class Dog {
bark() { console.log('woof') }
}
class Cat {
meow() { console.log('meow') }
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
// TypeScript 知道:既然 instanceof Dog 成功了,那肯定是 Dog
animal.bark()
} else {
// 到了 else,肯定是 Cat 没跑了(控制流分析)
animal.meow()
}
}

就像认人:typeof 看身份证(基本类型),instanceof 看脸(对象类型)。

in 操作符类型守护

对于非类对象的区分,有时候我们可以通过对象内部是否存在特定的属性来判断,这时候 in 运算符就派上用场了。

interface Bird {
fly(): void
layEggs(): void
}
interface Fish {
swim(): void
layEggs(): void
}
function move(animal: Bird | Fish) {
if ('fly' in animal) {
// 既然有 fly 方法,那肯定是 Bird 没跑了
animal.fly()
} else {
// 到了这里,只能是 Fish
animal.swim()
}
}

通过检查对象有没有某个属性,TypeScript 就能推断出具体类型。就像翻口袋找钥匙一样。

自定义类型守护

内置的收窄手段不够用?那就自己写!

可以写一个判断函数,关键是返回类型的标注,例如标注为 value is Bird,就是告诉 TypeScript:“只要这个函数返回 truevalue 就是 Bird 类型”。

interface Bird {
fly(): void
layEggs(): void
}
interface Fish {
swim(): void
layEggs(): void
}
// 类型守护:返回 true 表示这是 Bird
function isBird(value: Bird | Fish): value is Bird {
return 'fly' in value
}
// 类型守护:返回 true 表示这是 Fish
function isFish(value: Bird | Fish): value is Fish {
return 'swim' in value
}
function move(animal: Bird | Fish) {
if (isBird(animal)) {
// TypeScript 看到返回 true,就知道 animal 肯定是 Bird
animal.fly()
} else if (isFish(animal)) {
// 同理,这里肯定是 Fish
animal.swim()
}
}

自定义类型守护就像给 TypeScript 一个”特别提示”,让它知道你的判断逻辑,从而实现更灵活的类型收窄。

可区分联合

除此之外,还有种常见的联合类型设计,由一组相似的对象类型组成,其中包含一个专门用来区分类型的标志属性,这种类型设计,很适合通过简单判断进行区分,更常见的是配合 switch case 来使用,如下例子:

interface SuccessState {
// 区分标志
status: 'success'
data: string
}
interface ErrorState {
// 区分标志
status: 'error'
error: Error
}
interface LoadingState {
// 区分标志
status: 'loading'
}
// 对于 AsyncState,可以通过对 `status` 属性的判断来缩窄类型
type AsyncState = SuccessState | ErrorState | LoadingState
function handleState(state: AsyncState) {
switch (state.status) {
case 'success':
// 必定是 SuccessState
console.log('Data:', state.data)
break
case 'error':
// 必定是 ErrorState
console.error('Error:', state.error.message)
break
case 'loading':
// 必定是 LoadingState
console.log('Loading...')
break
default:
// 上面已经穷尽了,所以此处永远不可达
throw new Error('never')
}
}

Narrowing 让我们能精确控制类型的变化,但这只是 TypeScript 强大功能的一部分。接下来我们要聊的是让代码可重用的关键——泛型

为啥要讲泛型?想象一下:如果每个函数、接口、类都只能处理特定的类型,那我们得写多少重复代码?泛型就是为了解决这个问题,让你能写出”一套代码,适用所有类型”的通用逻辑。


泛型

泛型是 TypeScript 类型系统的基石,简单说就是:把类型也变成参数,让代码更通用、更可重用

先看个 JavaScript 里的例子:

function identity(arg) {
return arg
}
const num = identity(123)
const str = identity('string')

这个函数干的事情很简单:你给它啥,它就返回啥。输出的类型跟输入的类型一样。

现在的问题是:在 TypeScript 里怎么表达”输入啥类型就返回啥类型”这个约束?

如果只考虑类型安全,不考虑可重用,我们可以给每种类型都写一个函数:

function identityNum(arg: number): number {
return arg
}
function identityStr(arg: string): string {
return arg
}

但这显然太笨拙了——得写多少重复代码啊!

更好的办法是用一个”占位符”来表示类型,就像函数参数占位一样:

function identity(arg: 类型占位符): 类型占位符 {
return arg
}

在 TypeScript 里,这个”类型占位符”叫类型参数,用尖括号 <> 来声明。按照约定俗成的习惯,通常用大写字母 T 表示:

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

在这个函数声明中,参数和返回值都用 T,就有效地约束了:输入和输出的类型必须相同,但又不限定具体是啥类型

像这种使用了类型参数的函数,就叫泛型函数

那怎么用呢?调用时把 T 替换成具体的类型就行了,语法上还是用尖括号 <>

const num = identity<number>(123)
const str = identity<string>('string')

不过,TypeScript 挺聪明的,大多数情况下它能自动推断出类型,不用你每次都写:

const num = identity(123) // 自动推断 T 是 number
const str = identity('string') // 自动推断 T 是 string

就像你跟厨师说”给我做个菜”,厨师一看你拿了鸡蛋,就知道你要做番茄炒蛋了——不用你特意交代”这是鸡蛋哦”。

好了,通过这个简单的例子,我们已经了解了泛型的基本思路:把类型变成参数,让代码更通用。接下来看看泛型在实际开发中是怎么用的。

泛型函数

前面那个 identity 例子太简单了,我们看个稍微实际点的:

// 定义了两个类型参数:
// - T:输入数组中每一项的类型
// - U:输出数组中每一项的类型
function map<T, U>(fn: (item: T) => U, arr: T[]): U[] {
return arr.map(item => fn(item))
}
// TypeScript 自动推断:T 是 number,U 是 string
const strArr = map(String, [1, 2, 3])
// 结果:['1', '2', '3']

泛型接口

接口也能用类型参数,让它变成一个”万能适配器”:

interface Ref<T> {
value: T
}
const numRef: Ref<number> = { value: 123 }
const strRef: Ref<string> = { value: 'string' }

就像一个通用插座,不管插头是圆的、扁的还是方的,都能插进去用。这样 Ref 就不用为每种类型都写一个接口了。

泛型类

类同样可以用类型参数,效果跟泛型接口类似:

class Ref<T> {
value: T
constructor(value: T) {
this.value = value
}
}
// TypeScript 自动推断 T 是 number
const numRef = new Ref(123)

类和接口的主要区别在于类有实例方法,但类型参数的用法是一样的。

type

type 别名也可以加类型参数,跟接口的用法一样:

type Ref<T> = { value: T }
const numRef: Ref<number> = { value: 123 }

至此,我们也初步对 TypeScript 中的泛型有一定的印象了,接下来,就继续进阶一些展开。


泛型约束

在介绍泛型约束之前,让我们先思考一个实际问题。

考虑这样一个场景,我们需要编写一个函数,用于获取任意值的长度:

function getLength(arg: any): number {
return arg.length
}

使用 any 类型显然不够安全,因为我们可以传入任何类型的参数,包括没有 length 属性的类型,这会导致运行时错误。

那能不能用泛型来改进呢?

function getLength<T>(arg: T): number {
// 下面这行依然会报错: Property 'length' does not exist on type 'T'
return arg.length
}

问题很显然:泛型 T 可以是任何类型,TypeScript 没法确定 T 一定有 length 属性。

这很正常——因为 T 太自由了,啥都能装。我们得给它加点限制,告诉它:“只能是有 length 的类型”。这就是泛型约束的作用。

使用 extends 约束泛型

怎么给 T 加限制呢?用 extends 关键字——就像接口继承一样,只不过这里是给类型参数”划定范围”:

// 定义一个接口,声明一个包含数字类型 length 属性的数据结构
interface Lengthwise {
length: number
}
// 使用 extends 约束泛型 T 必须符合 Lengthwise 接口
function getLength<T extends Lengthwise>(arg: T): number {
// 此时就能安全访问 length 属性了
return arg.length
}
// 使用示例
const strLength = getLength('hello') // 字符串有 length 属性,返回 5
const arrLength = getLength([1, 2, 3]) // 数组有 length 属性,返回 3
const objLength = getLength({ length: 10, value: 'hi' }) // 对象有 length 属性,返回 10
// getLength(123) // 这行会编译错误: number 没有 length 属性

现在 T 不是”啥都能装”了,而是”只接受有 length 属性的类型”。简单,但很管用——类型安全一下子就上来了。

约束为对象属性

还有一种很实用的约束方式:把类型参数限制为某个对象的 key。这就要用上 keyof 操作符了:

// 约束 K 类型是 T 对象类型的 key
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { name: 'Alice', age: 30, email: 'alice@example.com' }
const name = getProperty(user, 'name') // 返回类型是 string
const age = getProperty(user, 'age') // 返回类型是 number
// getProperty(user, 'invalid') // 编译错误: 'invalid' 不是 user 的属性

这里发生了什么?我们分解一下:

  • 第一次调用时,T 被推断为 user 的类型,K 被确定为 'name'
  • 返回值类型 T[K] 就成了 T["name"],也就是 string
  • 第二次调用时,K 变成了 'age',返回值类型自然就是 number
  • 'invalid'?对不起,不在 keyof T 的范围内,直接报错

这个例子的精彩之处在于

  1. K extends keyof T 确保 key 参数只能是 obj 的合法属性名
  2. 返回值类型 T[K] 能根据传入的 key 自动推断出正确的类型——不需要手动标注
  3. 写错属性名会在编译期就暴露出来,不用等到运行时才踩坑

到这里,我们已经把泛型的基础打牢了——从最简单的泛型函数,到泛型约束,算是入了门。不过你可能也发现一个问题:TypeScript 总是在问你这个能不能赋值给那个,那个能不能换成这个

这其实就触及到了 TypeScript 类型系统的核心——类型兼容性

我们在前面其实已经多次碰到过这个概念了,只是当时没有点破:

  • Narrowing 章节里,联合类型在不同分支会表现为不同的具体类型
  • 泛型约束用的 extends 关键字,本质上也是在判断类型兼容关系
  • 甚至把 Dog 赋值给 Animal 类型变量,也是类型兼容性在起作用

接下来,我们就正式把 TypeScript 的类型兼容性规则理清楚。搞懂这个,写类型安全的代码就顺手多了。


类型兼容性

什么是类型兼容性?

说到”兼容”,我们第一反应可能是:A 能不能赋值给 B?在 TypeScript 里,这确实是类型兼容性要回答的核心问题。

但更准确地说,类型兼容性讨论的是类型之间的子类型关系

等等,这里的”子类型”不是指 class 的继承,而是类型系统里的一个概念:如果类型 S 能在任何需要类型 T 的地方安全地替换 T,那 S 就是 T 的子类型。

听着有点抽象?举个例子:

let animal: Animal = new Dog() // Dog 能赋值给 Animal

这说明 DogAnimal 的子类型——因为 Dog 可以在任何需要 Animal 的地方使用,不会出问题。

所以,我们讨论类型兼容性,本质上就是在问:这个类型能不能当作那个类型用?

结构化类型

TypeScript 使用的是结构化类型系统(Structural Typing),跟 Java、C# 那些语言的名义类型系统(Nominal Typing)不太一样。

有啥区别?简单说就是:TypeScript 看的是”长相”,不是”身份证”

只要两个类型的结构一样(成员、属性类型都匹配),TypeScript 就认为它们兼容,不在乎名字叫啥。

举个例子:

interface Person {
name: string
}
class User {
name: string
}
// User 类压根没声明 implements Person
// 但结构一样,所以能直接赋值
const person: Person = new User() // 没问题
// 对象字面量也一样,只要结构对得上
const person2: Person = { name: 'Alice' } // 没问题

在 Java 里头,即使 User 的结构跟 Person 一模一样,你也得显式写 implements Person 才能赋值。TypeScript 就没这么多讲究——结构对上就行,省事。

不过,这套”只看结构”的规则也带来了一个后果:类型兼容性的判断规则比较复杂。我们接下来就分门别类地讲清楚。

TypeScript 官方有个描述:
“The basic rule for TypeScript’s structural type system is that x is compatible with y if y has at least the same members as x.”
这个 “兼容” 的表述方式,我觉得并不直接,因此本文中,我增加了子类型关系的描述来辅助理解。

基础类型兼容

先从最基础的开始。

// 原始类型的兼容
let num: number = 42
let str: string = 'hello'
// num = str // 这样不行,string 不是 number 的子类型

基本类型的兼容规则很直观——每种类型各管各的,互不相干number 就是 numberstring 就是 string,不能混着用。

联合类型兼容

联合类型也好理解,核心原则就一句话:

越具体的(子类型),可以赋值给越宽泛的(父类型)

看个例子:

type T1 = string | number | boolean // 最宽泛
type T2 = string | number // 中等
type T3 = string // 最具体

按照上面的原则:

  1. T2T1 的子类型——T2 的值可以赋给 T1,反过来不行
  2. T3 既是 T1 也是 T2 的子类型——T3 可以赋给 T1T2,反过来也不行

为啥?我们推演一下就明白了。

假设有 t2: T2t3: T3 两个变量:

  • t3 赋给 t2 安全吗?安全!t3 永远是 string,而 t2 正好接受 string
  • t2 赋给 t3 安全吗?不安全!t3 只接受 string,但 t2 可能是个 number

用一个更具体的代码例子来说明:

type StringOrNumber = string | number
type StringOnly = string
let union: StringOrNumber = 'hello'
let single: StringOnly = 'world'
// 单一类型可以赋值给包含它的联合类型
union = single // 合法
// 联合类型不能直接赋值给单一类型
// single = union // 不合法,union 可能是 number
// 需要类型收窄后才能赋值
if (typeof union === 'string') {
single = union // 合法
}

对象类型兼容

对象类型就稍微复杂点了,得看结构是不是匹配。

规则其实就一句话:

目标类型 T 的每个成员,都能在源类型 S 里找到对应的兼容成员,那 TS 就兼容

说得再细一点,假设有两个类型 TS,如果 ST 的子类型(能赋值),那就得满足:

  1. T 的每个成员,S 里都得有
  2. T 的成员类型,跟 S 里对应的成员类型要兼容(S 中的成员的类型是 T 中对应成员类型的子类型)

第一点说明 S 可以有额外成员(T 没限制的),但 T 要求的成员 S 必须有。 第二点说明这个检查是递归的——成员类型也得做兼容性检查。

记住这个规则,后面分析类型兼容性问题全靠它

举个例子:

interface Animal {
name: string
}
interface Dog {
name: string
breed: string
}
const dog: Dog = { name: 'Buddy', breed: 'Golden Retriever' }
// Animal 的成员(name)在 Dog 里都有,且类型一样
// 所以 Dog 是 Animal 的子类型,能赋值
const animal: Animal = dog // 没问题
// 反过来呢?Dog 的成员(name 和 breed)Animal 都有吗?
// 显然没有,breed 不在 Animal 里
const dog2: Dog = animal // 报错

规则懂了,但为啥要这样设计?

逻辑很简单,我们推演一下:

  • 操作 Animal 的代码,最多只会访问 name 属性,而且期望它是 string
  • Dog 正好有 string 类型的 name,所以用 Dog 替换 Animal 很安全

反过来:

  • 操作 Dog 的代码,期望能访问 namebreed
  • Animal 缺少 breed,替换后代码访问 breed 就会出问题

所以这个规则不是 TypeScript 随便定的,是为了保证类型安全

可选属性和多余属性

当对象属性是可选的时候,兼容性怎么判断?

看个例子:

interface Optional {
name: string
age?: number // 可选属性
}
interface Required {
name: string
age: number
}

这两个类型能相互赋值吗?

根据前面的规则,推导一下:

  1. Optional 不能赋值给 Required
  2. Required 可以赋值给 Optional

为啥?看代码就明白了:

const optional: Optional = { name: 'Alice' }
const required: Required = optional // 报错

问题出在 age 上:

  • Required 要的是 number
  • Optional 的是 number | undefined(可选属性相当于加了个 undefined
  • number | undefined 不能赋给 number(前面联合类型讲过),所以不兼容

反过来就 OK 了:

const required: Required = { name: 'Bob', age: 30 }
const optional: Optional = required // 没问题
  • Optional 接受 number | undefined
  • Required 给的是 number
  • number 能赋给 number | undefined(更具体的能赋给更宽泛的),所以没问题

枚举类型兼容

TypeScript 的枚举有点特殊——这是少数没法单纯靠删类型就能还原成 JavaScript 的特性。它的兼容性规则也有点不一样,我们看例子。

// 数字枚举——没指定值的话,默认就是 0, 1, 2...
// 这种枚举跟 number 类型兼容
enum Status {
Pending,
Approved,
Rejected
}
let status: Status = Status.Pending
// 1. 枚举能赋给 number,没问题
let num: number = status
// 2. number 也能赋给枚举类型——但这很危险!
// 枚举的值是有限的,随便塞个数字进去,运行时可能出问题
num = 0
status = num // 能过编译,但别这么干
// 字符串枚举就不一样了
enum StringStatus {
Pending = 'PENDING',
Approved = 'APPROVED'
}
let strStatus: StringStatus = StringStatus.Pending
// 1. 字符串枚举能赋给 string,OK
let str: string = strStatus
// 2. 但 string 不能赋给字符串枚举——编译器直接拦住
str = 'PENDING'
strStatus = str // 报错

函数类型兼容

函数类型的兼容性规则就比较特殊了,得同时看参数和返回值。

下面是规则(以 TypeScript 2.6+ 和 strictFunctionTypes: true 模式为准):

假设有两个函数类型 T(目标)和 S(源),如果 S 能赋值给 TS 是子类型),那么:

  1. S 的每个参数,在 T 的相同位置都得有对应参数
  2. T 的参数类型,得是 S 对应参数类型的子类型(参数要逆变
  3. S 的返回值类型,得是 T 返回值类型的子类型(返回值要协变

第一点说明 S 的参数数量不能多于 T。 第二点有点反直觉——参数类型是反着来的(后面会细讲)。 第三点跟前面学的对象类型兼容一致——返回值越具体越好。

翻译成人话:

  • 参数:子类型的参数可以更少,类型可以更宽泛
  • 返回值:子类型的返回值要更具体(更窄)

看个例子:

let t = (x: number, y: number) => 0
let s = (x: number) => 0
t = s // 没问题
s = t // 报错

分析一下:

s 赋给 t

  • 目标类型 t(x: number, y: number) => number
  • 源类型 s(x: number) => number
  • s 的唯一参数 number,在 t 的第一个位置也是 number —— 匹配上了
  • 返回值都是 number —— 也匹配
  • 结论:st 的子类型,能赋值

t 赋给 s

  • 目标类型 s(x: number) => number
  • 源类型 t(x: number, y: number) => number
  • t 的第一个参数 number 能在 s 里找到
  • t 的第二个参数?s 根本没有第二个参数
  • 结论:不兼容,不能赋值

再看个 可选参数 的例子:

// 目标:参数都是必须的
type RequiredCallback = (name: string, age: number) => void
// 源:参数都是可选的
// 没问题——可选参数能满足必须参数的要求
const optionalCallback: RequiredCallback = (name?: string, age?: number) => {
console.log(name, age)
}
// 反过来就不行了
// 目标:参数是可选的
type OptionalCallback = (name?: string, age?: number) => void
// 源:参数都是必须的
// 报错——必须参数不能替换可选参数
const requiredCallback: OptionalCallback = (name: string, age: number) => {
console.log(name, age)
}

例子看懂了,但为啥要这么设计?这得从”协变”和”逆变”说起。

协变 / 逆变

先简单介绍三个术语:协变逆变不变

假设 Dog 继承自 Animal,这三个概念描述的就是泛型类型之间的关系:

  • 协变:方向一致。Producer<Dog> 可以当作 Producer<Animal> 用(只读,往外掏东西)
  • 逆变:方向相反。Consumer<Animal> 可以当作 Consumer<Dog> 用(只写,往里塞东西)
  • 不变:不能互相赋值。Box<Dog>Box<Animal> 必须完全匹配(可读可写)

函数有个特点:参数和返回值的变体规则不一样

函数参数——逆变

函数参数是逆变的——参数类型越宽松(往父类型方向),函数类型就越”具体”。

type DogHandler = (dog: Dog) => void
type AnimalHandler = (animal: Animal) => void
let handleDog: DogHandler = (dog: Dog) => {
dog.bark() // 调用 Dog 特有的方法
}
// handleDog 不能赋给 handleAnimal
// let handleAnimal: AnimalHandler = handleDog // 报错
// 但反过来可以
handleDog = ((animal: Animal) => {}) // 没问题

为啥参数要逆变?看个例子:

type DogHandler = (dog: Dog) => void
type AnimalHandler = (animal: Animal) => void
let handleDog: DogHandler = (dog: Dog) => {
dog.bark() // 这里会调用 Dog 特有的方法
}
// 假设允许这种赋值(实际上不允许)
let handleAnimal: AnimalHandler = handleDog
handleAnimal(new Animal()) // 运行时错误!Animal 没有 bark 方法

如果允许协变(handleDog 赋值给 handleAnimal),就会导致运行时错误。所以参数必须是逆变的,才能保证类型安全。

从读写角度理解

  • 函数参数是”写”操作(往函数里塞值)
  • 能处理 Animal 的函数,当然也能处理 Dog
  • 所以 AnimalHandler 可以当作 DogHandler 用(逆变)

函数返回值 - 协变

函数返回值是协变的,返回子类型的函数可以当作返回父类型的函数来用:

type AnimalFactory = () => Animal
type DogFactory = () => Dog
interface Animal {
name: string
}
interface Dog extends Animal {
breed: string
}
// 合法:返回 Dog 的函数可以当作返回 Animal 的函数
const createDog: AnimalFactory = () => {
return { name: 'Buddy', breed: 'Golden' }
}
// 不合法:返回 Animal 的函数不能当作返回 Dog 的函数
// const createAnimal: DogFactory = () => {
// return { name: 'Buddy' }
// }

从读写角度理解

  • 函数返回值是”读”操作(从函数里掏值)
  • 掏出来的是 Dog,当作 Animal 看完全没问题
  • 所以返回 Dog 的函数可以当作返回 Animal 的函数(协变)

简单来说

  • 函数参数是”写”,往里塞 → 逆变(能塞父类型就能塞子类型)
  • 函数返回值是”读”,往外掏 → 协变(掏出子类型可以当父类型)

这里先简单介绍了协变、逆变的概念,如果想深入理解,可以看后面的「协变、逆变与不变」章节,那里会有更详细的讲解,包括它们和”读写”操作的关系,以及数组、容器等类型的变体规则。


类类型兼容

类的类型兼容性跟对象字面量或 interface 类似,但有个特殊点:类有实例成员和静态成员之分

比较两个类实例的兼容性时,只看实例成员,静态成员和构造函数不参与比较。

class Animal {
name: string
constructor(name: string) {
this.name = name
}
}
class Dog {
name: string
breed: string
constructor(name: string, breed: string) {
this.name = name
this.breed = breed
}
}
const dog: Dog = new Dog('Buddy', 'Golden')
const animal: Animal = dog // 没问题,结构兼容

访问修饰符的影响

privateprotected 成员会让兼容性检查变得更严格。

先说结论:privateprotected 成员必须”同源”才能兼容。

啥叫”同源”?就是这些成员必须来自同一个父类声明

看个例子:

class Base {
protected name: string
constructor(name: string) {
this.name = name
}
}
class Derived1 extends Base {
constructor(name: string) {
super(name)
}
}
class Derived2 extends Base {
constructor(name: string) {
super(name)
}
}
// Derived1 和 Derived2 的 protected name 都来自 Base
// 它们是"同源"的,所以可以互相赋值
const d1: Derived1 = new Derived1('test')
const d2: Derived2 = d1 // 合法

但如果是不同的声明,即使结构一样,也不行:

class OtherBase {
protected name: string // 虽然也是 protected name,但不是来自 Base
constructor(name: string) {
this.name = name
}
}
class OtherDerived extends OtherBase {
constructor(name: string) {
super(name)
}
}
// OtherDerived 的 protected name 来自 OtherBase,不是 Base
// 所以不能兼容
const other: OtherDerived = d1 // 报错!不同源

为啥要这样?

因为 privateprotected 本身就有限制:外部不能随便访问。如果允许不同源的 private/protected 成员兼容,就可能绕过这个限制:

class Safe {
private secret: string = '我的秘密'
}
class Unsafe {
private secret: string = '也是秘密?'
steal() {
console.log(this.secret)
}
}
// 如果允许这样赋值(实际上不允许)
const safe: Safe = new Safe()
const unsafe: Unsafe = safe // 报错!
unsafe.steal() // 就能偷到 Safe 的 secret 了!

所以 TypeScript 要求 private/protected 成员必须”同源”,确保不会绕过访问限制。

简单说

  • public 成员:只看结构,结构相同就兼容
  • private/protected 成员:既要看结构,还要看”出身”(必须来自同一个声明)

泛型兼容性

泛型类型的兼容性有点复杂,得看类型参数怎么用。我们分几种情况来说:

1. 类型参数未被使用时

如果类型参数压根没在类型定义里用到,那它就不影响兼容性。

// 类型参数 T 完全没有被使用
interface Empty<T> {}
let empty1: Empty<number> = {}
let empty2: Empty<string> = empty1 // 没问题,T 没被用到
let empty3: Empty<boolean> = empty1 // 也没问题
// 不管用啥类型替换 T,Empty<T> 的结构都是一样的

这挺好理解——T 既然没用到,那 Empty<number>Empty<string> 实际上都是同一个空对象类型 {}

2. 类型参数被使用时

一旦类型参数被用上了,它的兼容性就会影响整个类型。

interface Ref<T> {
value: T
}
let numRef: Ref<number> = { value: 42 }
let strRef: Ref<string> = numRef // 不行,number 不兼容 string
let numRef2: Ref<number> = numRef // 没问题,完全相同的类型

逻辑也不难:Ref<number> 要求 valuenumberRef<string> 要求 valuestring,结构不兼容,当然不能互相赋值。

3. 泛型接口的兼容性

泛型接口的兼容性检查会递归地检查所有用到类型参数的地方。

interface Pair<T> {
first: T
second: T
}
interface PairWithMore<T> {
first: T
second: T
third: T
}
let pair: Pair<number> = { first: 1, second: 2 }
// Pair<number> 不能赋给 Pair<string>
let pair2: Pair<string> = pair // 报错
// 但 PairWithMore<number> 可以赋给 Pair<number>
let pairMore: PairWithMore<number> = { first: 1, second: 2, third: 3 }
pair = pairMore // 没问题——Pair<number> 的成员 PairWithMore<number> 都有

这个例子符合前面讲的对象类型兼容规则。

4. 泛型类的兼容性

泛型类的兼容性规则跟泛型接口类似:

class Storage<T> {
data: T
constructor(data: T) {
this.data = data
}
get(): T {
return this.data
}
}
let numStorage: Storage<number> = new Storage(100)
let strStorage: Storage<string> = numStorage // 不行
// 但如果类型参数未使用,则兼容
class NoUse<T> {
id: number
}
let noUse1: NoUse<number> = { id: 1 }
let noUse2: NoUse<string> = noUse1 // 没问题

5. 复杂的泛型兼容性

来看个更复杂的例子:

interface List<T> {
length: number
[index: number]: T
}
interface ReadonlyList<T> {
readonly length: number
readonly [index: number]: T
}
let list: List<number> = [1, 2, 3]
let readonlyList: ReadonlyList<number> = list // 合法,readonly 只是让属性变成只读,不影响兼容性

这个例子说明:即使加了 readonly 修饰符,只要其他方面兼容,类型就兼容。因为 readonly 只是类型检查用的,不影响运行时的结构。

类型断言与兼容性

有时候我们得告诉 TypeScript”我比类型检查器更懂这个类型”,这时就可以用类型断言:

interface User {
name: string
}
const data = { name: 'Alice', age: 30 }
// 使用 as 断言
const user = data as User // 没问题
// 尖括号语法(JSX 中不可用)
const user2 = <User>data // 没问题
// 但断言不能实现不可能的转换
const num = 42 as string // 编译错误,除非用双重断言
const num2 = 42 as unknown as string // 能过编译,但危险!

注意:类型断言只是告诉编译器”相信我”,不会做任何运行时检查。滥用类型断言会破坏类型安全,得小心。

协变、逆变与不变

前面我们多次提到”协变”、“逆变”这些术语。现在来系统性地理解下这些概念。

先假设有个继承关系:Dog 继承自 Animal(Dog → Animal)。

协变 (Covariance):只读,保持方向一致。

如果 DogAnimal 的子类型,那么 Producer<Dog> 也是 Producer<Animal> 的子类型。

直观理解:这个容器是生产者,只能读(往外吐东西)。吐出来的是 Dog,当作 Animal 用完全没问题——Dog 本来就是 Animal 嘛。所以方向保持一致:Producer<Dog>Producer<Animal>

// 协变:只能读,方向一致
interface Producer<T> {
make(): T // 只往外吐
}

逆变 (Contravariance):只写,方向反过来。

如果 DogAnimal 的子类型,那么 Consumer<Animal> 反而是 Consumer<Dog> 的子类型(注意反过来了)。

直观理解:这个容器是消费者,只能写(往里吞东西)。Consumer<Animal> 表示能写入任何 Animal,那写入 Dog 肯定也没问题——Dog 也是 Animal。所以方向反过来了:Consumer<Animal>Consumer<Dog>

// 逆变:只能写,方向反过来
interface Consumer<T> {
consume: (arg: T) => void // 只往里吞
}

不变 (Invariance):可读可写,两边不能互相赋值。

Box<Dog>Box<Animal> 不能相互赋值,必须是 Box<Dog> 赋值给 Box<Dog>

直观理解:这个容器既能读又能写。读的时候要求协变,写的时候要求逆变,两个方向打架了,为了安全只能保持不变——谁也别替代谁。

// 不变:可读可写,必须完全匹配
interface Box<T> {
set(value: T): void // 写
get(): T // 读
}

简单来说

  • 协变:只读,子类型可以当父类型用(读出来的是子,当父看没问题)
  • 逆变:只写,能写父类型的也能写子类型(写父的位置写子也行)
  • 不变:可读可写,读写方向冲突,必须完全一样

数组类型的协变问题

根据前面说的,可读可写的容器类型理论上应该是不变的。但数组有点特殊:

class Animal {
name: string = ''
}
class Dog extends Animal {
bark() { console.log('woof') }
}
// 数组既能读也能写,理论上应该是不变的
// 但 TypeScript 允许这种协变赋值
const dogs: Dog[] = [new Dog()]
const animals: Animal[] = dogs // 允许协变
// 问题来了:通过 animals 引用可以写入 Animal
animals.push(new Animal()) // 把 Animal 塞进了 Dog 数组!
const dog = dogs[0] // 读出来的可能不是 Dog
dog.bark() // 运行时错误!

数组既能读(dogs[0])又能写(animals.push()),读写方向冲突,理论上应该保持不变。但 TypeScript 为了实用性,选择了允许协变,把安全性责任交给了开发者。

解决方案:只读数组

如果数组是只读的,那就只有读操作,协变就是安全的:

// readonly 数组只能读,不能写
const readonlyDogs: readonly Dog[] = [new Dog()]
const readonlyAnimals: readonly Animal[] = readonlyDogs // 安全的协变
readonlyAnimals.push(new Animal()) // 报错,不允许写

容器类型的协变问题

和数组一样,普通的 Box<T> 也是既能读又能写的:

interface Box<T> {
value: T
getValue(): T // 读
setValue(value: T): void // 写
}
const dogBox: Box<Dog> = {
value: new Dog(),
setValue(dog: Dog) { this.value = dog },
getValue() { return this.value }
}
// Box 既能读又能写,理论上应该是不变的
// 但 TypeScript 同样允许了协变赋值
const animalBox: Box<Animal> = dogBox
// 问题出现了
animalBox.setValue(new Animal()) // 通过写操作塞入了 Animal
const dog = dogBox.getValue() // 通过读操作取出来的可能不是 Dog
dog.bark() // 运行时错误:dog.bark is not a function

同样的逻辑:

  • getValue() 是读操作,要求协变(Box<Dog>Box<Animal>
  • setValue() 是写操作,要求逆变(Box<Animal>Box<Dog>
  • 读写方向冲突,理论上应该保持不变

但 TypeScript 为了实用性,还是选择了允许协变,让开发者自己注意类型安全。

简单说

  • 只读容器(readonly)→ 协变安全
  • 只写容器 → 逆变安全
  • 可读可写容器 → 理论上应该不变,但 TypeScript 允许协变(需开发者小心)

泛型接口的变体标注(高级主题)

TypeScript 4.7 加上了 inout 关键字,用来标注类型参数的变体。不过先说清楚:这是个高级功能,解决非常具体的问题,平时你基本用不到它

TypeScript 基于结构化类型系统,这种系统中为了比较两个对象的类型,如 Producer<Animal>Producer<Dog>,通常的算法是会展开这两个定义的结构,然后进行比较。而如果能明确变体关系则可以做到有效的性能优化,比如说知道 Producer<T>T 是协变的,那么上述两个类型,就不用展开定义,而只需简单比较 AnimalDog 就足够了。因为协变决定了 Producer<Animal>Producer<Dog> 的关系,和 AnimalDog 的关系是一致的。

然后,TypeScript 的结构化类型系统里,比较两个泛型类型时,TypeScript 其实会自己推断它们的变体关系:

// 协变:Producer<Cat> 能赋值给 Producer<Animal>
// 推断:out T
interface Producer<T> {
make(): T
}
// 逆变:Consumer<Animal> 能赋值给 Consumer<Cat>
// 推断: in T
interface Consumer<T> {
consume: (arg: T) => void
}

所以,通常不需要自己手动标注 inout

官方文档中三令五申的强调不要手动标注变体注释

既然通常没必要手动标注,那自然就有特殊情况可能需要手工加 inout 标注。根据对官方文档的理解,手动标注的用处有:

1. 类型调试

// 错!这个接口明显是逆变的,但标注为协变
interface Foo<out T> {
consume: (arg: T) => void // 编译器会报错
}

TypeScript 会检查你的标注对不对。如果标错了,编译器会告诉你。

2. 性能优化

处理超级复杂的类型时,通过手工标注,能提升编译器类型检查的性能。

怎么标注
// 逆变标注
interface Consumer<in T> {
consume: (arg: T) => void
}
// 协变标注
interface Producer<out T> {
make(): T
}
// 不变标注(同时用 in 和 out)
interface ProducerConsumer<in out T> {
consume: (arg: T) => void
make(): T
}
但有几个重要限制

只在实例化比较时生效

变体标注只在比较同一个泛型类型的两个实例时才起作用,结构化比较时就没用了。

先看一个生效的例子:

interface Producer<out T> {
make(): T
}
class Animal {}
class Dog extends Animal {
bark() {}
}
// 实例化比较:同一个泛型类型的不同实例
let dogProducer: Producer<Dog> = {
make: () => new Dog()
}
let animalProducer: Producer<Animal> = dogProducer // 合法,协变生效
let dogProducer2: Producer<Dog> = animalProducer // 不合法,变体标注阻止了反方向赋值

在这个例子中,Producer<Dog>Producer<Animal> 是同一个泛型类型的两个实例,变体标注会生效。

再看一个不生效的例子(结构化比较):

interface Producer<out T> {
make(): T
}
// 变体标注在这里不生效,仍然是结构化比较
const p: Producer<string | number> = {
make(): number {
return 42
}
}

这里 p 的类型是 Producer<string | number>,而赋值的对象字面量是匿名类型(不是 Producer<number>),所以是结构化比较,变体标注不生效。

不能用来”强制”变体行为

你别想着用变体标注来改变类型的实际行为:

// 别这么干!
interface Producer<in out T> {
make(): T
}

即使你标成 in out(不变),TypeScript 做结构化比较时,还是会根据实际使用方式来判断。

必须匹配结构行为

变体标注得和类型的结构行为一致。

这点官方文档有强调,请确保标注本身是正确的

啥时候用变体标注?

基本不用!

官方文档都说了:

  • TypeScript 自己会推断变体关系
  • 变体标注只在特定场景下才生效
  • 不能用来改变类型检查行为
  • 主要就是用于极少数的性能优化和类型调试

如果你发现自己需要用变体标注,很可能是这些情况:

  • 正在调试类型,想验证某个类型的变体关系
  • 遇到了极其复杂的类型导致性能问题,而且已经通过 profiler 确认是变体推断的问题
  • 处理某些循环类型,自动推断不太准确

一句话建议:正常开发中,让 TypeScript 自己处理变体关系就完了,别手动加 in/out 标注。

any / unknown

any 是一个特殊的类型,使用它,相当于退化回 JavaScript,完全取消任何限制,同时也完全放弃类型安全,因此,除非迫不得已,永远不要使用,因为它能在编译期放你一马,但是在运行期可能就给你来个大烟花。

unknown 也是特殊的,它本质上相当于所有类型的父类型,在某些场景中,我们无法确定类型的时候,用它是相对安全的,因为结合类型守护可以安全进行操作。

举例说明:

let str: string = 'hello'
let num: any = 123
str = num // 合法,编译器允许你这样做,但是后果自负。
str.slice(0) // 爆炸,因为 str 是个 string,编译器认为调用 slice 方法是合法的,但实际上其值已经被覆盖为 number 了

对比 unknown 的使用场景:

let str: string = 'hello'
let unknownValue: unknown = 123
// str = unknownValue // 不合法,不能直接赋值,因为本质 unknown 相当于父类型,无法安全将父类型赋值给子类型,编译器会阻止我们犯错
// 配合类型守护
if (typeof unknownValue === 'string') {
// 此时 unknownValue 被 narrowing 到 string,可以安全赋值,非法值进不来这里
str = unknownValue
}

后续将开始一些更加深入的内容,也就是类型体操所需要具备的知识。

这部分知识,在日常业务开发中,基本用不上,但是库和框架开发者就很可能需要用上。

高级类型操作

条件类型

先从简单的开始,看看什么是条件类型。

TypeScript 的类型系统里,我们可以根据某些条件得到不同的类型。虽然 TypeScript 没有提供 if 判断,但给了一种三元表达式的形式来实现:

需要判断的类型 extends 某种类型条件 ? 检测成功时返回的类型 : 检测失败时返回的类型

这个三元表达式咋理解?

  1. extends 理解成判断前面的类型是不是后面类型的子类型
  2. 如果是,就返回 ? 后面的类型
  3. 如果不是,就返回 : 后面的类型

条件类型是后面复杂类型体操的基础,得好好掌握。看几个例子:

// string[] 是 any[] 的子类型,所以 T1 为 true
type T1 = string[] extends any[] ? true : false // T1 类型为 true
// string 不是 any[] 的子类型,所以 T2 为 false
type T2 = string extends any[] ? true : false // T2 类型为 false

实际使用中,我们通常会把它封装成工具类型:

type IsArray<T> = T extends any[] ? true : false

上面的例子就可以改写成:

type T1 = IsArray<string[]> // T1 类型为 true
type T2 = IsArray<string> // T2 类型为 false

通过抽象出各种辅助工具类型,我们就能组合出各种复杂的类型。

分布式条件类型

当条件类型作用于联合类型时,会自动”分发”到联合类型的每个成员上。这叫分布式条件类型

// 普通条件类型
type ToArray<T> = T extends any ? T[] : never
// 用于联合类型时,会自动分发
type NumArr = ToArray<number> // number[]
type StrArr = ToArray<string> // string[]
// 关键:作用于联合类型时,会分发到每个成员
type Mixed = ToArray<string | number> // string[] | number[]

看着挺正常,但这个行为其实很有用:

// 过滤掉 null 和 undefined
type NonNullable<T> = T extends null | undefined ? never : T
type Test1 = NonNullable<string | null> // string
type Test2 = NonNullable<number | undefined> // number
type Test3 = NonNullable<string | null | undefined> // string

工作原理:

type Filter<T> = T extends any ? T : never
// 当传入 string | number 时:
// 1. 自动分解为:Filter<string> | Filter<number>
// 2. 分别判断后合并
type Result = Filter<string | number> // string | number

什么时候会分发?

只有当条件类型的形式是 T extends U ? X : Y,而且 T 是一个裸类型参数(没有被数组、Promise、元组等包裹)时,才会自动分发:

// 会分发:T 是裸类型参数
type ToArray<T> = T extends any ? T[] : never
type Distributed = ToArray<string | number>
// 等价于:ToArray<string> | ToArray<number>
// 结果:string[] | number[]
// 不会分发:T 被元组包裹了
type ToArrayNonDist<T> = [T] extends any ? T[] : never
type NonDistributed = ToArrayNonDist<string | number>
// 不会分解,直接用 (string | number) 整体判断
// 结果:(string | number)[]

再看一个过滤的例子:

// 会分发:过滤出字符串类型
type OnlyStrings<T> = T extends string ? T : never
type Test1 = OnlyStrings<string | number | boolean>
// 分发到每个成员:
// OnlyStrings<string> | OnlyStrings<number> | OnlyStrings<boolean>
// = string | never | never
// 结果:string
// 不会分发:T 被数组包裹
type OnlyStringsNonDist<T> = T[] extends string[] ? T : never
type Test2 = OnlyStringsNonDist<string | number>
// 整体判断:(string | number)[] 不继承自 string[]
// 结果:never

如何避免分发?

如果想阻止自动分发,可以用元组或其他方式把类型参数包裹起来:

// 方法1:用元组包裹
type NoDistribute<T> = [T] extends any ? T : never
type Res1 = NoDistribute<string | number> // string | number(不会分解)
// 方法2:使用交叉类型
type NoDistribute2<T> = T & {} extends any ? T : never
// 实际应用:把联合类型整体处理
type ToArrayNonDistributive<T> = [T] extends any ? T[] : never
type Mixed = ToArrayNonDistributive<string | number> // (string | number)[]
// 而不是 string[] | number[]

实际应用场景

// 提取对象类型的某个属性,并分发到联合类型
type Pluck<T, K extends keyof T> = T[K]
interface User {
name: string
age: number
}
interface Product {
name: string
price: number
}
type Names = Pluck<User | Product, 'name'> // string
// 获取函数所有参数类型的联合
type Parameters<T> = T extends (...args: infer P) => any ? P : never
type Func = (a: string, b: number) => void
type Params = Parameters<Func> // [string, number]

分布式条件类型让类型操作更灵活,但也注意别意外触发。理解何时分发、何时不分发,能帮你更精确地控制类型行为。

never 的使用

never 类型在 TypeScript 中表示”永远不会出现的值”。它在条件类型中常用于过滤不需要的类型:

// 过滤掉联合类型中的某个类型
type Exclude<T, U> = T extends U ? never : T
type Result = Exclude<string | number | boolean, number>
// = Exclude<string, number> | Exclude<number, number> | Exclude<boolean, number>
// = string | never | boolean
// = string | boolean
// 提取联合类型中的某个类型
type Extract<T, U> = T extends U ? T : never
type Result2 = Extract<string | number | boolean, number | string>
// = string | number
// 过滤掉 null 和 undefined
type NonNullable<T> = T extends null | undefined ? never : T
type Result3 = NonNullable<string | null | undefined> // string

类型推断与 infer

使用 infer 关键字让我们可以在条件类型中声明一个待推断的类型变量,TypeScript 会自动推断出这个变量具体是什么类型。

它有用的点是,能从一个复杂类型里把需要的部分 “抓” 出来。

怎么理解?我们接下来看一些例子来感受一下。

从函数返回值中 “掏” 类型

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never
// ^^^^^
// 这里声明了一个待推断的 R
// TypeScript 会自动找出返回值类型

看实际使用:

function greet(): string {
return 'hello'
}
function add(a: number, b: number): number {
return a + b
}
// TypeScript 自动 "抓" 出了返回值类型
type GreetReturn = ReturnType<typeof greet> // string
type AddReturn = ReturnType<typeof add> // number

就像从盒子里掏东西,infer R 告诉 TypeScript:“去函数返回值位置看看,把那里的类型记下来,赋给 R”。

注:TypeScript 已经自带 ReturnType 工具类型了。

从数组中 “掏” 元素类型

type ElementType<T> = T extends (infer U)[] ? U : never
// ^^^^^
// 推断数组元素的类型
type numbers = number[]
type strings = string[]
type Num = ElementType<numbers> // number
type Str = ElementType<strings> // string

从 Promise 中 “掏” 值类型

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
// ^^^^^
// 把 Promise 里的值类型掏出来
type AsyncString = Promise<string>
type AsyncNumber = Promise<number>
type UnwrappedString = UnwrapPromise<AsyncString> // string
type UnwrappedNumber = UnwrapPromise<AsyncNumber> // number
// 如果不是 Promise,就原样返回
type NotPromise = UnwrapPromise<string> // string

这就好比把快递盒拆了,直接拿出里面的东西。

从函数参数中”掏”类型

// 只掏第一个参数的类型
type FirstParameter<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never
// ^^^^^
// 推断第一个参数的类型
type Func = (a: string, b: number) => void
type First = FirstParameter<Func> // string
// 实用:掏出所有参数类型
type Parameters<T> = T extends (...args: infer P) => any ? P : never
type Params = Parameters<(a: string, b: number, c: boolean) => void>
// 结果:[string, number, boolean]

注:TypeScript 已经自带 Parameters 工具类型了。

链式推断:一环扣一环

infer 还可以多次使用,像链条一样把类型一层层拆开:

// 把 Promise 返回的函数类型的参数掏出来
type PromiseReturnType<T> = T extends Promise<infer U>
? U extends (...args: infer P) => any
? P
: never
: never
type ComplexType = Promise<(x: string, y: number) => void>
type Result = PromiseReturnType<ComplexType>
// 结果:[string, number]

类型抓取流程:

  1. 先拆 Promise,拿到函数类型 (x: string, y: number) => void
  2. 再拆函数参数,拿到 [string, number]

看了以上几个例子之后,相信已经对 infer 的能力有了清晰的认知了。那么接下来就梳理下 infer 的使用规则。

infer 的使用规则

记住几个要点:

  1. 只能在条件类型中使用T extends ... ? ... : ... 这种形式
  2. 总是出现在 extends 后面:不能在条件的 ?(true)/ :(false) 分支中声明
  3. 可以有多个 infer:一次掏多个部分

映射类型

类型映射,就是基于现有类型,批量修改所有属性,创造出一个新类型。

同样,下面通过列举使用场景来理解。

基础映射:给所有属性统一加点料

// 把所有属性变成只读
type Readonly<T> = {
readonly [P in keyof T]: T[P]
// ^^^^^^^^ 遍历 T 的所有属性
// ^^^^^^ 加上 readonly 修饰符
}
// 把所有属性变成可选
type Partial<T> = {
[P in keyof T]?: T[P]
// ^^ 给每个属性加上 ?
}

看实际效果:

interface User {
name: string
age: number
email: string
}
// 所有属性都变成只读了
type ReadonlyUser = Readonly<User>
// 等价于:
// {
// readonly name: string
// readonly age: number
// readonly email: string
// }
// 所有属性都变成可选了
type PartialUser = Partial<User>
// 等价于:
// {
// name?: string
// age?: number
// email?: string
// }

这就像给用户的每个属性都贴了个”只读”标签,或者给每个属性都加了个”可选”标记。不用一个一个改,一次性搞定。

注:TypeScript 中自带有 Readonly、Partial 工具类型

去掉修饰符:反向操作

不仅能加,还能减:

// 去掉 readonly
type Mutable<T> = {
-readonly [P in keyof T]: T[P]
// ^^^^^^ 前面的 - 号表示去掉 readonly
}
// 去掉可选
type Required<T> = {
[P in keyof T]-?: T[P]
// ^^ 前面的 - 号表示去掉 ?
}
interface User {
name?: string
age?: number
email?: string
}
// 去掉可选属性,全部必须属性
type RequiredUser = Required<User>
// {
// name: string
// age: number
// email: string
// }
interface ReadonlyUser {
readonly name: string
readonly age: number
}
// 去掉只读,变成可修改
type MutableUser = Mutable<ReadonlyUser>
// {
// name: string // 可以修改了
// age: number // 可以修改了
// }

注:TypeScript 自带 Required 工具类型

键值重映射:改头换面

最强大的功能是还能同时改属性名:

type Getters<T> = {
[P in keyof T as `get${Capitalize<P & string>}`]: () => T[P]
//^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 遍历每个属性 P,然后把它改名为 get + 首字母大写的 P
}
interface Person {
name: string
age: number
email: string
}
type PersonGetters = Getters<Person>
// 等价于:
// {
// getName: () => string
// getAge: () => number
// getEmail: () => string
// }

这就像把一个对象的每个属性都”配对”了一个 getter 方法。name 变 getName,age 变 getAge,一次性全部搞定。

实际应用场景

// 1. 表单编辑:把所有字段改成可选
type FormFields<T> = Partial<T>
interface UserProfile {
name: string
age: number
avatar: string
}
type EditForm = FormFields<UserProfile>
// 用户编辑时,不用填完所有字段
const form: EditForm = {
name: 'Alice' // 只填一部分也行
}
// 2. API 响应:把所有字段改成只读
type ReadonlyResponse<T> = Readonly<T>
type ApiResponse = ReadonlyResponse<UserProfile>
// 从 API 拿到的数据不能直接修改
// 3. 事件处理器:批量创建事件方法
type EventHandlers<T extends string> = {
[K in T as `on${Capitalize<K>}`]: (...args: any[]) => void
}
type ButtonEvents = EventHandlers<'click' | 'hover' | 'focus'>
// {
// onClick: (...args: any[]) => void
// onHover: (...args: any[]) => void
// onFocus: (...args: any[]) => void
// }

映射模板

映射类型的基本语法:

type MappedType<T> = {
[P in keyof T]: T[P]
//^^^^^^^^^^^^ 遍历 T 的每个属性键 P
// ^^^^ 使用 P 获取对应的属性值
}

可以加修饰符:

  • +readonlyreadonly:变成只读
  • -readonly:去掉只读
  • +??:变成可选
  • -?:去掉可选

简单说:映射类型让你能批量”改装”类型的所有属性,就像用模版复印文件一样,一次改完所有的。


模板字面量类型

模板字面量类型就像是类型的”拼装积木”——用字符串模板,把不同类型的字符串拼在一起,创造出新的类型。

这跟 JavaScript 的模板字符串语法一模一样,只不过这次是用来玩转类型的。

基础模板:拼装字符串

// 最简单的模板类型
type Greeting = `hello ${string}`
// ^^^^^^^^^^^^^^^^
// 必须以 "hello " 开头,后面可以跟任何字符串
const g1: Greeting = 'hello world' // 合法
const g2: Greeting = 'hello TypeScript' // 合法
const g3: Greeting = 'hi world' // 错误!不是以 "hello " 开头

这就像在说:“我要一个以 ‘hello ’ 开头的字符串,后面随便你填啥”。

联合类型的魔法:一变多

当模板遇上联合类型,排列组合开始:

type Color = 'red' | 'blue' | 'green'
type Quantity = 'light' | 'dark'
type ColorVariant = `${Quantity} ${Color}`
// ^^^^^^^^^^^^^^^^^^^^^
// 两个联合类型"相乘":3 × 2 = 6 种
// 结果:
// 'light red' | 'light blue' | 'light green' |
// 'dark red' | 'dark blue' | 'dark green'

每个组合都生成一个类型!

模式匹配:提取类型

不仅能拼,还能拆:

// 提取事件名
type ExtractEventName<T> = T extends `on${infer Name}` ? Name : never
type ClickName = ExtractEventName<'onClick'> // 'Click'
type HoverName = ExtractEventName<'onHover'> // 'Hover'
type Invalid = ExtractEventName<'click'> // never(没有 "on" 前缀)
// 提取颜色和深浅
type ExtractColor<T> = T extends `${infer Color}-${infer Shade}` ? { color: Color; shade: Shade } : never
type BlueDark = ExtractColor<'blue-dark'>
// 结果:{ color: 'blue'; shade: 'dark' }

实际应用场景:事件处理器批量生成方法名

// 先定义一个"事件名生成器"
type EventName<T extends string> = `on${Capitalize<T>}`
// ^^^^^^^^^^^^^^
// 首字母大写,然后加上 "on" 前缀
type ClickEvent = EventName<'click'> // 'onClick'
type HoverEvent = EventName<'hover'> // 'onHover'
type FocusEvent = EventName<'focus'> // 'onFocus'
// 批量生成:联合类型进来,一堆事件名出去
type AllEvents = EventName<'click' | 'hover' | 'focus'>
// 结果:'onClick' | 'onHover' | 'onFocus'

然后配合映射类型,一整套事件处理器就出来了:

type EventHandler<T extends string> = {
[K in EventName<T>]: (event: { type: K }) => void
// ^^^^^^^^^^^^^^^^^ 遍历所有生成的事件名
}
type ButtonEvents = EventHandler<'click' | 'hover'>
// 等价于:
// {
// onClick: (event: { type: 'onClick' }) => void
// onHover: (event: { type: 'onHover' }) => void
// }

实际应用场景:Getter/Setter 生成器

// 批量创建 getter/setter
type Accessors<T extends string> = {
[K in T as `get${Capitalize<K>}`]: () => any
} & {
[K in T as `set${Capitalize<K>}`]: (value: any) => void
}
type UserAccessors = Accessors<'name' | 'age'>
// {
// getName: () => any
// setName: (value: any) => void
// getAge: () => any
// setAge: (value: any) => void
// }

模板字面量类型让你能像搭积木一样,用字符串模板创造出无限可能的类型组合,特别适合批量生成方法和属性名。


类型体操案例

前面讲了那么多基础概念,现在来点实际的——类型体操。TypeScript 的类型系统强大到让你能用类型编程来解决各种复杂问题。

深度操作

// DeepReadonly - 深度只读
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}
interface Nested {
a: {
b: {
c: string
}
}
}
type DeepNested = DeepReadonly<Nested>
// 所有层级的属性都变成只读了
// DeepPartial - 深度可选
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
// DeepRequired - 深度必需
type DeepRequired<T> = {
[P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P]
}

核心思想:递归。如果是对象类型,就继续往下处理;如果不是,就停下来。

复杂类型变换

// PromiseValue - 获取多层 Promise 的值类型
type PromiseValue<T> = T extends Promise<infer U>
? U extends Promise<any>
? PromiseValue<U>
: U
: T
type Data = Promise<Promise<string>>
type Unwrapped = PromiseValue<Data> // string
// Flatten - 展平数组类型
type Flatten<T extends any[]> = T extends [infer First, ...infer Rest]
? First extends any[]
? [...Flatten<First>, ...Flatten<Rest>]
: [First, ...Flatten<Rest>]
: []
type Nested = [1, [2, [3, 4]], 5]
type Flat = Flatten<Nested> // [1, 2, 3, 4, 5]
// camelizeCase - 驼峰命名转换
type Camelize<S extends string> =
S extends `${infer First}_${infer Rest}`
? `${First}${Capitalize<Camelize<Rest>>}`
: S
type Camelized = Camelize<'hello_world_foo_bar'>
// 'helloWorldFooBar'

这些例子的核心都是:拆开 → 处理 → 重组。用好 infer 和条件类型,你就能实现各种复杂的类型变换。

TODO: 更多复杂案例补全中


内置工具类型

TypeScript 自带了一堆超实用的工具类型,就像一个装满工具的瑞士军刀箱。这些工具类型能帮你快速完成常见的类型转换,不用每次都手写。

官方工具类型文档

我们按分类快速过一遍:

属性修饰工具

  • Partial<T> - 把所有属性变成可选(?),很适合表单编辑场景
  • Required<T> - 把所有属性变成必填,去掉可选标记
  • Readonly<T> - 把所有属性变成只读(readonly),防止意外修改

结构操作工具

  • Pick<T, K> - 从类型中挑选指定的几个属性
  • Omit<T, K> - 从类型中去掉指定的几个属性
  • Record<K, T> - 构造一个对象类型,键是 K,值是 T

联合类型工具

  • Exclude<T, U> - 从联合类型 T 中排除 U
  • Extract<T, U> - 从联合类型 T 中提取符合 U 的部分
  • NonNullable<T> - 从 T 中排除 nullundefined

函数类型工具

  • ReturnType<T> - 获取函数的返回值类型
  • Parameters<T> - 获取函数的参数类型(元组形式)
  • ConstructorParameters<T> - 获取构造函数的参数类型
  • ThisParameterType<T> - 获取函数的 this 类型
  • OmitThisParameter<T> - 去掉函数的 this 参数类型

字符串操作工具

  • Uppercase<S> - 转成大写
  • Lowercase<S> - 转成小写
  • Capitalize<S> - 首字母大写
  • Uncapitalize<S> - 首字母小写

其他实用工具

  • Awaited<T> - 获取 Promise 的最终类型(解包嵌套的 Promise)
  • NoInfer<T> - 阻止类型推断,强制显式指定类型
  • InstanceType<T> - 获取类构造函数的实例类型

这些工具类型在日常开发中经常用到,记得翻翻官方文档,总能找到适合你的那个。


TypeScript 类型系统的独特之处

TypeScript 的类型系统在编程语言世界里可以说是独树一帜。

它既不同于 Java、C# 这样的传统静态类型语言,也不同于 Haskell、OCaml 这样的纯函数式语言。

我们来深入了解下 TypeScript 类型系统的独特优势。

类型编程 (Type-Level Programming)

这是 TypeScript 最独特的能力之一:

// 类型层面的算术运算(使用数组长度模拟数字)
type Add<A extends number, B extends number> =
[...Tuple<A>, ...Tuple<B>]['length']
type Tuple<L extends number, T extends any[] = []> =
T['length'] extends L ? T : Tuple<L, [...T, any]>
type Length<T extends any[]> = T['length']
type Three = Add<1, 2> // 3
// 实际有用的类型编程
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? DeepPartial<T[P]>
: T[P]
}
// 路径操作类型
type Path<T, K extends keyof T> =
K extends string
? T[K] extends object
? K | `${K}.${Path<T[K], keyof T[K]>}`
: K
: never
interface Config {
database: {
host: string
port: number
}
server: {
port: number
}
}
type ConfigPaths = Path<Config, keyof Config>
// 'database' | 'server' | 'database.host' | 'database.port' | 'server.port'

渐进式类型 (Gradual Typing)

// 可以从无类型逐步过渡到有类型
// 阶段 1:完全没有类型
function add(a, b) {
return a + b
}
// 阶段 2:添加部分类型
function addPartial(a: number, b) {
return a + b
}
// 阶段 3:完全类型化
function addFull(a: number, b: number): number {
return a + b
}
// 可以使用 any 作为逃逸口
function processExternalLibrary(data: any) {
// 处理没有类型定义的外部库
return data.someMethod()
}
// 或者使用 unknown 更安全
function processSafely(data: unknown) {
if (typeof data === 'object' && data !== null) {
// 类型守护
return (data as any).someMethod()
}
}

强大的类型推断

// TypeScript 可以从上下文中推断出复杂的类型
const users = [
{ name: 'Alice', age: 30, roles: ['admin', 'user'] },
{ name: 'Bob', age: 25, roles: ['user'] },
]
// 自动推断出类型
type User = typeof users[number]
// {
// name: string
// age: number
// roles: string[]
// }
// 函数返回值类型推断
function createUser(name: string, age: number) {
return {
name,
age,
roles: [] as string[],
createdAt: new Date(),
}
}
// TypeScript 推断出返回类型
type NewUser = ReturnType<typeof createUser>
// {
// name: string
// age: number
// roles: string[]
// createdAt: Date
// }
// 复杂的类型推断
function mapObject<T extends Record<string, any>, U>(
obj: T,
fn: <K extends keyof T>(value: T[K], key: K) => U
): Record<keyof T, U> {
const result = {} as Record<keyof T, U>
for (const key in obj) {
result[key] = fn(obj[key], key)
}
return result
}
const result = mapObject(
{ a: 1, b: 2, c: 3 },
(value) => value.toString()
)
// result 的类型被推断为 { a: string; b: string; c: string }

联合类型与交叉类型

// 联合类型:一个值可以是多种类型之一
type StringOrNumber = string | number
function process(value: StringOrNumber) {
if (typeof value === 'string') {
return value.toUpperCase() // 这里 value 是 string
}
return value.toFixed(2) // 这里 value 是 number
}
// 交叉类型: 组合多个类型
type Name = { name: string }
type Age = { age: number }
type User = Name & Age
const user: User = {
name: 'Alice',
age: 30,
}
// 联合类型的判别
interface Success {
success: true
data: string
}
interface Error {
success: false
error: string
}
type Result = Success | Error
function handleResult(result: Result) {
if (result.success) {
// 这里 result 被识别为 Success
console.log(result.data)
} else {
// 这里 result 被识别为 Error
console.log(result.error)
}
}

模板字面量类型

// 这是 TypeScript 独有的强大特性
type EventName<T extends string> = `on${Capitalize<T>}`
type ClickEvent = EventName<'click'> // 'onClick'
type FocusEvent = EventName<'focus'> // 'onFocus'
// 甚至可以进行字符串操作
type Split<S extends string, D extends string> =
S extends `${infer T}${D}${infer U}`
? T | Split<U, D>
: S
type PathSegments = Split<'a.b.c', '.'>
// 'a' | 'b' | 'c'

TypeScript 不是理论最完美的类型系统,但它是最实用的类型系统之一。它在保持 JavaScript 灵活性的同时,提供了强大的类型安全保证,这让构建大规模 JavaScript 应用变得既安全又高效。


参考资源


全文完