TypeScript 类型系统: 从基础到体操
前言
现代化大型软件开发,静态类型语言已经是主流共识了。
项目越复杂、人越多,人脑就越难掌控全局。这时候,编程语言的类型系统加上 IDE 的辅助就显得格外重要。
前端领域这几年发展迅猛,JavaScript 项目也变得越来越复杂。开发和维护都越来越专业化、越来越有挑战性。好在有了 TypeScript——它强大的类型系统让复杂项目的开发变得容易多了。
本文想系统性地梳理下 TypeScript 的类型系统,帮我们温故知新,查漏补缺。
TypeScript 类型基础回顾
在深入复杂类型之前,我们先快速过一遍 TypeScript 的基础类型系统,就像打比赛前先热个身。
// 原始类型 - 最基础的积木块const str: string = 'hello'const num: number = 42const bool: boolean = trueconst sym: symbol = Symbol('foo')const big: bigint = 100n
// 特殊类型 - 几个"怪咖"const nullValue: null = nullconst undef: undefined = undefinedconst 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:“只要这个函数返回 true,value 就是 Bird 类型”。
interface Bird { fly(): void layEggs(): void}
interface Fish { swim(): void layEggs(): void}
// 类型守护:返回 true 表示这是 Birdfunction isBird(value: Bird | Fish): value is Bird { return 'fly' in value}
// 类型守护:返回 true 表示这是 Fishfunction 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 是 numberconst 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 是 stringconst 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 是 numberconst 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 属性,返回 5const arrLength = getLength([1, 2, 3]) // 数组有 length 属性,返回 3const objLength = getLength({ length: 10, value: 'hi' }) // 对象有 length 属性,返回 10
// getLength(123) // 这行会编译错误: number 没有 length 属性现在 T 不是”啥都能装”了,而是”只接受有 length 属性的类型”。简单,但很管用——类型安全一下子就上来了。
约束为对象属性
还有一种很实用的约束方式:把类型参数限制为某个对象的 key。这就要用上 keyof 操作符了:
// 约束 K 类型是 T 对象类型的 keyfunction 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') // 返回类型是 stringconst 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的范围内,直接报错
这个例子的精彩之处在于:
K extends keyof T确保key参数只能是obj的合法属性名- 返回值类型
T[K]能根据传入的key自动推断出正确的类型——不需要手动标注 - 写错属性名会在编译期就暴露出来,不用等到运行时才踩坑
到这里,我们已经把泛型的基础打牢了——从最简单的泛型函数,到泛型约束,算是入了门。不过你可能也发现一个问题:TypeScript 总是在问你这个能不能赋值给那个,那个能不能换成这个。
这其实就触及到了 TypeScript 类型系统的核心——类型兼容性。
我们在前面其实已经多次碰到过这个概念了,只是当时没有点破:
- Narrowing 章节里,联合类型在不同分支会表现为不同的具体类型
- 泛型约束用的
extends关键字,本质上也是在判断类型兼容关系 - 甚至把
Dog赋值给Animal类型变量,也是类型兼容性在起作用
接下来,我们就正式把 TypeScript 的类型兼容性规则理清楚。搞懂这个,写类型安全的代码就顺手多了。
类型兼容性
什么是类型兼容性?
说到”兼容”,我们第一反应可能是:A 能不能赋值给 B?在 TypeScript 里,这确实是类型兼容性要回答的核心问题。
但更准确地说,类型兼容性讨论的是类型之间的子类型关系。
等等,这里的”子类型”不是指 class 的继承,而是类型系统里的一个概念:如果类型 S 能在任何需要类型 T 的地方安全地替换 T,那 S 就是 T 的子类型。
听着有点抽象?举个例子:
let animal: Animal = new Dog() // Dog 能赋值给 Animal这说明 Dog 是 Animal 的子类型——因为 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 = 42let str: string = 'hello'
// num = str // 这样不行,string 不是 number 的子类型基本类型的兼容规则很直观——每种类型各管各的,互不相干。number 就是 number,string 就是 string,不能混着用。
联合类型兼容
联合类型也好理解,核心原则就一句话:
越具体的(子类型),可以赋值给越宽泛的(父类型)。
看个例子:
type T1 = string | number | boolean // 最宽泛type T2 = string | number // 中等type T3 = string // 最具体按照上面的原则:
T2是T1的子类型——T2的值可以赋给T1,反过来不行T3既是T1也是T2的子类型——T3可以赋给T1或T2,反过来也不行
为啥?我们推演一下就明白了。
假设有 t2: T2 和 t3: T3 两个变量:
- 把
t3赋给t2安全吗?安全!t3永远是string,而t2正好接受string - 把
t2赋给t3安全吗?不安全!t3只接受string,但t2可能是个number
用一个更具体的代码例子来说明:
type StringOrNumber = string | numbertype StringOnly = string
let union: StringOrNumber = 'hello'let single: StringOnly = 'world'
// 单一类型可以赋值给包含它的联合类型union = single // 合法
// 联合类型不能直接赋值给单一类型// single = union // 不合法,union 可能是 number
// 需要类型收窄后才能赋值if (typeof union === 'string') { single = union // 合法}对象类型兼容
对象类型就稍微复杂点了,得看结构是不是匹配。
规则其实就一句话:
目标类型 T 的每个成员,都能在源类型 S 里找到对应的兼容成员,那 T 和 S 就兼容。
说得再细一点,假设有两个类型 T 和 S,如果 S 是 T 的子类型(能赋值),那就得满足:
T的每个成员,S里都得有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的代码,期望能访问name和breed Animal缺少breed,替换后代码访问breed就会出问题
所以这个规则不是 TypeScript 随便定的,是为了保证类型安全。
可选属性和多余属性
当对象属性是可选的时候,兼容性怎么判断?
看个例子:
interface Optional { name: string age?: number // 可选属性}
interface Required { name: string age: number}这两个类型能相互赋值吗?
根据前面的规则,推导一下:
Optional不能赋值给RequiredRequired可以赋值给Optional
为啥?看代码就明白了:
const optional: Optional = { name: 'Alice' }const required: Required = optional // 报错问题出在 age 上:
Required要的是numberOptional的是number | undefined(可选属性相当于加了个undefined)number | undefined不能赋给number(前面联合类型讲过),所以不兼容
反过来就 OK 了:
const required: Required = { name: 'Bob', age: 30 }const optional: Optional = required // 没问题Optional接受number | undefinedRequired给的是numbernumber能赋给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 = 0status = num // 能过编译,但别这么干
// 字符串枚举就不一样了enum StringStatus { Pending = 'PENDING', Approved = 'APPROVED'}
let strStatus: StringStatus = StringStatus.Pending
// 1. 字符串枚举能赋给 string,OKlet str: string = strStatus// 2. 但 string 不能赋给字符串枚举——编译器直接拦住str = 'PENDING'strStatus = str // 报错函数类型兼容
函数类型的兼容性规则就比较特殊了,得同时看参数和返回值。
下面是规则(以 TypeScript 2.6+ 和 strictFunctionTypes: true 模式为准):
假设有两个函数类型 T(目标)和 S(源),如果 S 能赋值给 T(S 是子类型),那么:
S的每个参数,在T的相同位置都得有对应参数T的参数类型,得是S对应参数类型的子类型(参数要逆变)S的返回值类型,得是T返回值类型的子类型(返回值要协变)
第一点说明
S的参数数量不能多于T。 第二点有点反直觉——参数类型是反着来的(后面会细讲)。 第三点跟前面学的对象类型兼容一致——返回值越具体越好。
翻译成人话:
- 参数:子类型的参数可以更少,类型可以更宽泛
- 返回值:子类型的返回值要更具体(更窄)
看个例子:
let t = (x: number, y: number) => 0let 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—— 也匹配 - 结论:
s是t的子类型,能赋值
把 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) => voidtype 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) => voidtype 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 = () => Animaltype 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 // 没问题,结构兼容访问修饰符的影响
private 和 protected 成员会让兼容性检查变得更严格。
先说结论:private 和 protected 成员必须”同源”才能兼容。
啥叫”同源”?就是这些成员必须来自同一个父类声明。
看个例子:
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 // 报错!不同源为啥要这样?
因为 private 和 protected 本身就有限制:外部不能随便访问。如果允许不同源的 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 不兼容 stringlet numRef2: Ref<number> = numRef // 没问题,完全相同的类型逻辑也不难:Ref<number> 要求 value 是 number,Ref<string> 要求 value 是 string,结构不兼容,当然不能互相赋值。
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):只读,保持方向一致。
如果 Dog 是 Animal 的子类型,那么 Producer<Dog> 也是 Producer<Animal> 的子类型。
直观理解:这个容器是生产者,只能读(往外吐东西)。吐出来的是 Dog,当作 Animal 用完全没问题——Dog 本来就是 Animal 嘛。所以方向保持一致:Producer<Dog> → Producer<Animal>。
// 协变:只能读,方向一致interface Producer<T> { make(): T // 只往外吐}逆变 (Contravariance):只写,方向反过来。
如果 Dog 是 Animal 的子类型,那么 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 引用可以写入 Animalanimals.push(new Animal()) // 把 Animal 塞进了 Dog 数组!const dog = dogs[0] // 读出来的可能不是 Dogdog.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()) // 通过写操作塞入了 Animalconst dog = dogBox.getValue() // 通过读操作取出来的可能不是 Dogdog.bark() // 运行时错误:dog.bark is not a function同样的逻辑:
getValue()是读操作,要求协变(Box<Dog>→Box<Animal>)setValue()是写操作,要求逆变(Box<Animal>→Box<Dog>)- 读写方向冲突,理论上应该保持不变
但 TypeScript 为了实用性,还是选择了允许协变,让开发者自己注意类型安全。
简单说:
- 只读容器(
readonly)→ 协变安全 - 只写容器 → 逆变安全
- 可读可写容器 → 理论上应该不变,但 TypeScript 允许协变(需开发者小心)
泛型接口的变体标注(高级主题)
TypeScript 4.7 加上了 in 和 out 关键字,用来标注类型参数的变体。不过先说清楚:这是个高级功能,解决非常具体的问题,平时你基本用不到它。
TypeScript 基于结构化类型系统,这种系统中为了比较两个对象的类型,如 Producer<Animal> 和 Producer<Dog>,通常的算法是会展开这两个定义的结构,然后进行比较。而如果能明确变体关系则可以做到有效的性能优化,比如说知道 Producer<T> 的 T 是协变的,那么上述两个类型,就不用展开定义,而只需简单比较 Animal 和 Dog 就足够了。因为协变决定了 Producer<Animal> 与 Producer<Dog> 的关系,和 Animal 与 Dog 的关系是一致的。
然后,TypeScript 的结构化类型系统里,比较两个泛型类型时,TypeScript 其实会自己推断它们的变体关系:
// 协变:Producer<Cat> 能赋值给 Producer<Animal>// 推断:out Tinterface Producer<T> { make(): T}
// 逆变:Consumer<Animal> 能赋值给 Consumer<Cat>// 推断: in Tinterface Consumer<T> { consume: (arg: T) => void}所以,通常不需要自己手动标注 in、 out。
官方文档中三令五申的强调不要手动标注变体注释
既然通常没必要手动标注,那自然就有特殊情况可能需要手工加 in 和 out 标注。根据对官方文档的理解,手动标注的用处有:
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 = 123str = 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 某种类型条件 ? 检测成功时返回的类型 : 检测失败时返回的类型
这个三元表达式咋理解?
- 把
extends理解成判断前面的类型是不是后面类型的子类型 - 如果是,就返回
?后面的类型 - 如果不是,就返回
:后面的类型
条件类型是后面复杂类型体操的基础,得好好掌握。看几个例子:
// string[] 是 any[] 的子类型,所以 T1 为 truetype T1 = string[] extends any[] ? true : false // T1 类型为 true
// string 不是 any[] 的子类型,所以 T2 为 falsetype T2 = string extends any[] ? true : false // T2 类型为 false实际使用中,我们通常会把它封装成工具类型:
type IsArray<T> = T extends any[] ? true : false上面的例子就可以改写成:
type T1 = IsArray<string[]> // T1 类型为 truetype 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 和 undefinedtype NonNullable<T> = T extends null | undefined ? never : T
type Test1 = NonNullable<string | null> // stringtype Test2 = NonNullable<number | undefined> // numbertype 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) => voidtype 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 和 undefinedtype 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> // stringtype 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> // numbertype 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> // stringtype 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) => voidtype 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]类型抓取流程:
- 先拆 Promise,拿到函数类型
(x: string, y: number) => void - 再拆函数参数,拿到
[string, number]
看了以上几个例子之后,相信已经对 infer 的能力有了清晰的认知了。那么接下来就梳理下 infer 的使用规则。
infer 的使用规则
记住几个要点:
- 只能在条件类型中使用:
T extends ... ? ... : ...这种形式 - 总是出现在
extends后面:不能在条件的 ?(true)/ :(false) 分支中声明 - 可以有多个 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 工具类型
去掉修饰符:反向操作
不仅能加,还能减:
// 去掉 readonlytype 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 获取对应的属性值}可以加修饰符:
+readonly或readonly:变成只读-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/settertype 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 中排除 UExtract<T, U>- 从联合类型 T 中提取符合 U 的部分NonNullable<T>- 从 T 中排除null和undefined
函数类型工具
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> : Stype PathSegments = Split<'a.b.c', '.'>// 'a' | 'b' | 'c'TypeScript 不是理论最完美的类型系统,但它是最实用的类型系统之一。它在保持 JavaScript 灵活性的同时,提供了强大的类型安全保证,这让构建大规模 JavaScript 应用变得既安全又高效。
参考资源
全文完