Skip to content

TypeScript 高级类型

泛型基础与高级用法

泛型是 TypeScript 类型系统中最核心的抽象机制。它允许在定义函数、接口、类时不预先指定具体类型,而在使用时再传入类型参数,从而实现类型安全的代码复用。泛型的本质是类型层面的参数化多态(Parametric Polymorphism)——将类型作为参数传递,让同一段逻辑适用于不同的类型。

泛型函数

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

const str = identity('hello')
const num = identity(42)

当调用 identity('hello') 时,TypeScript 编译器通过类型参数推断(Type Argument Inference) 自动将 T 推断为 string,无需手动标注 identity<string>('hello')。推断的本质是编译器在调用点将实参类型与形参类型进行统一化(Unification) 运算,解出类型变量的绑定。

泛型接口与泛型类

ts
interface Repository<T> {
  getById(id: string): T
  getAll(): T[]
  save(entity: T): void
}

class NumberRepository implements Repository<number> {
  private data: Map<string, number> = new Map()

  getById(id: string): number {
    return this.data.get(id) ?? 0
  }

  getAll(): number[] {
    return Array.from(this.data.values())
  }

  save(entity: number): void {
    this.data.set(String(entity), entity)
  }
}

泛型类的静态成员不能引用类型参数,因为静态成员属于类构造函数本身而非实例。类型参数仅在实例侧的类型空间中有效。

泛型约束

通过 extends 关键字对泛型参数施加约束,限制其必须满足某种结构。

ts
interface Lengthwise {
  length: number
}

function logLength<T extends Lengthwise>(arg: T): T {
  console.log(arg.length)
  return arg
}

logLength('hello')
logLength([1, 2, 3])
logLength({ length: 10, value: 'test' })

约束的本质是对类型参数建立一个上界(Upper Bound)T extends Lengthwise 意味着 T 必须是 Lengthwise 的子类型——即结构上至少包含 length: number 属性。

更强大的约束模式——使用类型参数约束另一个类型参数:

ts
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const person = { name: 'Alice', age: 30 }
const name = getProperty(person, 'name')
const age = getProperty(person, 'age')

这里 K extends keyof T 确保 key 必须是 obj 的已知属性名之一,返回类型 T[K] 是索引访问类型,精确地表达了"取对象某个属性的值类型"这一语义。

泛型默认值

泛型参数可以指定默认类型,当调用方未显式传入且编译器无法推断时,使用默认值。

ts
interface ApiResponse<T = unknown> {
  code: number
  message: string
  data: T
}

const res1: ApiResponse = { code: 200, message: 'ok', data: 'anything' }
const res2: ApiResponse<string[]> = { code: 200, message: 'ok', data: ['a', 'b'] }

泛型默认值遵循与函数默认参数相同的规则——有默认值的类型参数必须排在没有默认值的类型参数之后:

ts
type CreateElement<Tag extends string, Props = {}, Children extends any[] = []> = {
  tag: Tag
  props: Props
  children: Children
}

type Div = CreateElement<'div'>
type Button = CreateElement<'button', { disabled: boolean }>
type List = CreateElement<'ul', {}, [string, string]>

泛型推断的高级场景

TypeScript 的类型推断在涉及多个泛型参数和复杂数据流时会进行多阶段推断(Multi-phase Inference)。理解推断的优先级和方向性,对编写类型友好的 API 至关重要。

ts
function merge<T, U>(a: T, b: U): T & U {
  return { ...a, ...b }
}

const result = merge({ name: 'Alice' }, { age: 30 })

当推断出现冲突时,TypeScript 会尝试寻找最佳公共类型(Best Common Type)

ts
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0]
}

const mixed = firstElement([1, 'hello', true])

此时 T 被推断为 string | number | boolean,因为数组字面量的元素类型被联合化处理。

泛型推断在回调函数中的上下文类型推断(Contextual Typing):

ts
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn)
}

const lengths = map(['hello', 'world'], item => item.length)

item 的类型被从 arr 的实参推断出 T = string,然后流入回调函数的参数类型,使得 item 自动获得 string 类型。这是 TypeScript 推断引擎中协同推断(Co-inference) 的典型体现。


条件类型

条件类型是 TypeScript 2.8 引入的核心特性,它将类型层面的 if-else 逻辑引入了类型系统。其语法形式为 T extends U ? X : Y,语义是:如果 T 可赋值给 U,则类型为 X,否则为 Y。条件类型使得 TypeScript 的类型系统成为了图灵完备的——它本质上是一种类型层面的模式匹配递归计算工具。

基本条件类型

ts
type IsString<T> = T extends string ? true : false

type A = IsString<string>
type B = IsString<number>
type C = IsString<'hello'>

AtrueBfalseCtrue(字面量类型 'hello'string 的子类型)。

分布式条件类型

当条件类型中的被检查类型(T)是一个裸类型参数(Naked Type Parameter) 且被实例化为联合类型时,条件类型会自动分布(Distribute) 到联合的每个成员上。

ts
type ToArray<T> = T extends any ? T[] : never

type Result = ToArray<string | number>

Result 的类型是 string[] | number[],而非 (string | number)[]。分布过程等价于:

ts
type Result = (string extends any ? string[] : never) | (number extends any ? number[] : never)

关键概念:"裸类型参数"是指 T 直接出现在 extends 左侧,未被任何其他类型包裹。一旦 T 被包裹,分布行为就会被阻止:

ts
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never

type Result2 = ToArrayNonDist<string | number>

Result2 的类型是 (string | number)[],因为 [T]T 包裹在了元组中,阻止了分布。

利用分布式条件类型的经典应用——过滤联合类型:

ts
type Filter<T, U> = T extends U ? never : T

type Result3 = Filter<'a' | 'b' | 'c' | 'd', 'a' | 'c'>

Result3'b' | 'd'。这正是内置工具类型 Exclude 的实现原理。

infer 关键字

infer 用于在条件类型中声明一个待推断的类型变量。它只能出现在 extends 子句中,作用是让编译器从实际类型中提取(Extract) 出某个位置的类型。

ts
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never

type R1 = ReturnTypeOf<() => string>
type R2 = ReturnTypeOf<(x: number) => boolean>
type R3 = ReturnTypeOf<string>

R1stringR2booleanR3never

infer 提取函数参数类型:

ts
type FirstParam<T> = T extends (first: infer P, ...rest: any[]) => any ? P : never

type P1 = FirstParam<(name: string, age: number) => void>
type P2 = FirstParam<() => void>

P1stringP2never

infer 提取数组元素类型:

ts
type ElementOf<T> = T extends (infer E)[] ? E : never

type E1 = ElementOf<string[]>
type E2 = ElementOf<[number, string, boolean]>

E1stringE2number | string | boolean(元组展开为联合)。

infer 提取 Promise 内部类型:

ts
type UnpackPromise<T> = T extends Promise<infer U> ? UnpackPromise<U> : T

type UP1 = UnpackPromise<Promise<string>>
type UP2 = UnpackPromise<Promise<Promise<number>>>
type UP3 = UnpackPromise<string>

UP1stringUP2number(递归展开),UP3string

infer 在字符串模板字面量中的使用:

ts
type TrimLeft<S extends string> = S extends ` ${infer Rest}` ? TrimLeft<Rest> : S

type Trimmed = TrimLeft<'   hello'>

Trimmed'hello'

infer 的协变与逆变位置对推断结果的影响:

ts
type Foo<T> = T extends { a: infer U; b: infer U } ? U : never

type T1 = Foo<{ a: string; b: string }>
type T2 = Foo<{ a: string; b: number }>

T1stringT2string | number。当同一个类型变量 U 出现在多个协变位置时,推断结果为联合类型。

ts
type Bar<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never

type T3 = Bar<{ a: (x: string) => void; b: (x: string) => void }>
type T4 = Bar<{ a: (x: string) => void; b: (x: number) => void }>

T3stringT4string & number(即 never)。当同一个类型变量 U 出现在多个逆变位置(函数参数位置)时,推断结果为交叉类型。


映射类型

映射类型(Mapped Types)允许基于已有类型创建新类型——遍历一个类型的所有属性键,并对每个属性进行转换。其语法形式为 { [P in K]: T },其中 P 是遍历变量,K 是键的联合类型,T 是每个属性的新类型。映射类型的本质是类型层面的 for...in 循环

keyof 操作符

keyof 用于获取一个类型的所有公共属性键,返回一个字符串字面量(或数字字面量、symbol)的联合类型。

ts
interface User {
  name: string
  age: number
  email: string
}

type UserKeys = keyof User

UserKeys 的类型为 'name' | 'age' | 'email'

keyof 作用于索引签名时的行为:

ts
type StringIndex = keyof { [key: string]: unknown }
type NumberIndex = keyof { [key: number]: unknown }

StringIndexstring | number(因为 JavaScript 中数字索引最终也会转为字符串),NumberIndexnumber

基本映射类型

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

type Optional<T> = {
  [P in keyof T]?: T[P]
}

type ReadonlyUser = Readonly<User>
type OptionalUser = Optional<User>

in 关键字在映射类型中充当迭代器角色,逐一遍历联合类型中的每个成员。T[P]索引访问类型(Indexed Access Type),表示类型 T 中属性 P 对应的值类型。

修饰符操作(+/- 修饰符)

映射类型中可以使用 +- 前缀来添加或移除 readonly? 修饰符。

ts
type Mutable<T> = {
  -readonly [P in keyof T]: T[P]
}

type Required<T> = {
  [P in keyof T]-?: T[P]
}

interface Config {
  readonly host: string
  readonly port: number
  database?: string
}

type MutableConfig = Mutable<Config>
type RequiredConfig = Required<Config>

-readonly 移除只读修饰符,-? 移除可选修饰符。+readonly+? 分别添加只读和可选修饰符(+ 可以省略,因为默认就是添加)。

as 子句重映射

TypeScript 4.1 引入了映射类型中的 as 子句,允许在遍历过程中对键进行转换。

ts
type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P]
}

interface Person {
  name: string
  age: number
}

type PersonGetters = Getters<Person>

PersonGetters 的类型为 { getName: () => string; getAge: () => number }

利用 as 子句过滤属性——当键被映射为 never 时,该属性会被移除:

ts
type OmitByType<T, U> = {
  [P in keyof T as T[P] extends U ? never : P]: T[P]
}

interface Mixed {
  name: string
  age: number
  active: boolean
  email: string
}

type WithoutStrings = OmitByType<Mixed, string>

WithoutStrings 的类型为 { age: number; active: boolean }

重映射结合模板字面量类型进行键的批量转换:

ts
type EventMap<T> = {
  [P in keyof T as `on${Capitalize<string & P>}Change`]: (value: T[P]) => void
}

type FormEvents = EventMap<{ name: string; age: number }>

FormEvents 的类型为 { onNameChange: (value: string) => void; onAgeChange: (value: number) => void }


模板字面量类型

模板字面量类型(Template Literal Types)是 TypeScript 4.1 引入的特性,它将 JavaScript 的模板字符串语法提升到了类型层面。通过模板字面量类型,可以在类型系统中进行字符串的拼接、拆分和模式匹配。

基本用法

ts
type Greeting = `hello ${string}`

type World = `hello ${'world'}`

type EventName = `${'click' | 'scroll' | 'mousemove'}Handler`

Greeting 匹配所有以 'hello ' 开头的字符串,World 精确为 'hello world'EventName'clickHandler' | 'scrollHandler' | 'mousemoveHandler'

当模板字面量类型中包含联合类型时,会产生笛卡尔积式的分布

ts
type Color = 'red' | 'blue'
type Size = 'small' | 'large'
type Style = `${Color}-${Size}`

Style'red-small' | 'red-large' | 'blue-small' | 'blue-large',是两个联合类型的完全组合。

内置字符串操作类型

TypeScript 提供了四个内置的字符串操作类型,它们是编译器内部实现的固有类型(Intrinsic Types),无法通过用户代码实现。

ts
type U = Uppercase<'hello'>
type L = Lowercase<'HELLO'>
type C = Capitalize<'hello'>
type UC = Uncapitalize<'Hello'>

U'HELLO'L'hello'C'Hello'UC'hello'

它们在映射类型中的实际应用——将对象属性名转换为各种命名风格:

ts
type CamelToSnake<S extends string> = S extends `${infer Head}${infer Tail}`
  ? Tail extends Uncapitalize<Tail>
    ? `${Lowercase<Head>}${CamelToSnake<Tail>}`
    : `${Lowercase<Head>}_${CamelToSnake<Tail>}`
  : S

type R = CamelToSnake<'getUserName'>

R'get_user_name'。这个实现通过递归逐字符扫描:如果下一个字符 Tail 的首字母是小写(等于 Uncapitalize<Tail>),说明没有到达驼峰边界,直接转小写拼接;否则说明遇到了大写字母边界,插入下划线。

模板字面量与 infer 的组合

模板字面量类型与 infer 结合可以实现字符串的解析和拆分:

ts
type Split<S extends string, D extends string> = S extends `${infer Head}${D}${infer Tail}`
  ? [Head, ...Split<Tail, D>]
  : [S]

type Parts = Split<'a-b-c-d', '-'>

Parts['a', 'b', 'c', 'd']

从路径字符串中提取参数:

ts
type ExtractRouteParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
  ? { [K in Param | keyof ExtractRouteParams<Rest>]: string }
  : T extends `${string}:${infer Param}`
    ? { [K in Param]: string }
    : {}

type Params = ExtractRouteParams<'/user/:id/post/:postId'>

Params{ id: string; postId: string }


内置工具类型源码解析

TypeScript 内置了一系列工具类型(Utility Types),它们全部由条件类型、映射类型等基础特性组合而成。理解它们的源码实现,是掌握高级类型的关键。

Partial<T>

将所有属性变为可选:

ts
type Partial<T> = {
  [P in keyof T]?: T[P]
}

通过映射类型遍历 T 的所有键,给每个属性添加 ? 修饰符。keyof T 获取所有公共属性键的联合,T[P] 获取每个键对应的值类型,? 将属性标记为可选(类型变为 T[P] | undefined)。

ts
interface User {
  name: string
  age: number
}

type PartialUser = Partial<User>

PartialUser 等价于 { name?: string; age?: number }

Required<T>

将所有属性变为必选,是 Partial 的逆操作:

ts
type Required<T> = {
  [P in keyof T]-?: T[P]
}

-? 语法移除可选修饰符。如果属性原本就是必选的,-? 不会产生副作用。

Readonly<T>

将所有属性变为只读:

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

添加 readonly 修饰符后,属性在类型层面不可被重新赋值。需要注意的是 readonly 是浅层的——嵌套对象的属性仍然可变。

Pick<T, K>

从类型 T 中挑选指定的属性子集:

ts
type Pick<T, K extends keyof T> = {
  [P in K]: T[P]
}

K extends keyof T 约束了 K 必须是 T 的属性键的子集。映射时只遍历 K 中的键,而非 keyof T 的所有键。

ts
type UserBasic = Pick<User, 'name'>

Omit<T, K>

从类型 T 中排除指定的属性:

ts
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>

OmitPickExclude 的组合。首先 Exclude<keyof T, K>T 的所有键中排除 K,然后 PickT 中挑选剩余的键。注意 K 的约束是 keyof any(即 string | number | symbol)而非 keyof T,这意味着可以传入 T 上不存在的键而不会报错。

Record<K, T>

构造一个属性键为 K、值类型为 T 的对象类型:

ts
type Record<K extends keyof any, T> = {
  [P in K]: T
}
ts
type PageInfo = Record<'home' | 'about' | 'contact', { title: string; url: string }>

Record 本质上是一个限制了键集合的映射类型,常用于构造字典或查找表。

Exclude<T, U>

从联合类型 T 中排除可赋值给 U 的成员:

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

利用分布式条件类型,对 T 联合类型的每个成员进行检查,如果该成员可赋值给 U,返回 never(从联合中移除),否则保留。

ts
type T = Exclude<'a' | 'b' | 'c', 'a' | 'b'>

T'c'

Extract<T, U>

从联合类型 T 中提取可赋值给 U 的成员,与 Exclude 互补:

ts
type Extract<T, U> = T extends U ? T : never
ts
type T = Extract<string | number | boolean, string | number>

Tstring | number

NonNullable<T>

从类型中排除 nullundefined

ts
type NonNullable<T> = T & {}

这个实现利用了交叉类型的特性:T & {} 会将 nullundefined 从联合中过滤掉,因为 null & {}undefined & {} 都是 never。早期版本的实现是 T extends null | undefined ? never : T,新版本使用交叉类型更为简洁。

ts
type T = NonNullable<string | number | null | undefined>

Tstring | number

ReturnType<T>

获取函数类型的返回值类型:

ts
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any

约束 T 必须是函数类型,然后通过 infer R 在返回值位置推断出返回类型。

ts
type R = ReturnType<() => { name: string; age: number }>

R{ name: string; age: number }

Parameters<T>

获取函数类型的参数类型,以元组形式返回:

ts
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never

infer P 在参数列表位置推断出整个参数的元组类型。

ts
type P = Parameters<(name: string, age: number) => void>

P[name: string, age: number]

InstanceType<T>

获取构造函数类型的实例类型:

ts
type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any

abstract new (...args: any) => any 匹配所有构造函数(包括抽象类),infer R 推断出构造函数创建的实例类型。

ts
class Animal {
  name: string = ''
}

type A = InstanceType<typeof Animal>

AAnimal。注意需要 typeof Animal 获取 Animal 的构造函数类型,而非实例类型。

Awaited<T>

递归展开 Promise 类型,获取最终解析的值类型(TypeScript 4.5 引入):

ts
type Awaited<T> = T extends null | undefined
  ? T
  : T extends object & { then(onfulfilled: infer F, ...args: infer _): any }
    ? F extends (value: infer V, ...args: infer _) => any
      ? Awaited<V>
      : never
    : T

这个实现的精妙之处在于:它不直接检查 Promise<T>,而是检查 then 方法的存在——即匹配所有 thenable 对象。首先排除 null | undefined,然后检查是否有 then 方法,如果有,则提取 onfulfilled 回调的参数类型,并递归展开。

ts
type A = Awaited<Promise<string>>
type B = Awaited<Promise<Promise<number>>>
type C = Awaited<boolean | Promise<string>>

AstringBnumberCboolean | string


类型体操实战

类型体操(Type Gymnastics)是指利用 TypeScript 类型系统的组合能力,在纯类型层面实现复杂的逻辑变换。以下是几个经典的高级类型实现。

DeepPartial

将对象类型的所有属性(包括嵌套属性)递归地变为可选:

ts
type DeepPartial<T> = T extends object
  ? T extends Function
    ? T
    : { [P in keyof T]?: DeepPartial<T[P]> }
  : T

interface NestedConfig {
  database: {
    host: string
    port: number
    credentials: {
      username: string
      password: string
    }
  }
  cache: {
    ttl: number
    enabled: boolean
  }
}

type PartialConfig = DeepPartial<NestedConfig>

实现要点:首先检查 T 是否是 object 类型(排除原始类型),然后检查是否是 Function(函数也是 object,但不应被展开),最后对剩余的对象类型递归应用 DeepPartialPartialConfigdatabase.credentials.username 等深层属性全部变为可选。

DeepReadonly

将对象类型的所有属性(包括嵌套属性)递归地变为只读:

ts
type DeepReadonly<T> = T extends Function
  ? T
  : T extends object
    ? { readonly [P in keyof T]: DeepReadonly<T[P]> }
    : T

interface State {
  user: {
    name: string
    preferences: {
      theme: string
      lang: string
    }
  }
  items: number[]
}

type ImmutableState = DeepReadonly<State>

ImmutableState 中所有层级的属性都被标记为 readonly。由于数组也是 objectnumber[] 会被转换为 readonly number[]{ readonly [P in keyof number[]]: ... } 会保留数组的结构并添加 readonly)。

TupleToUnion

将元组类型转换为联合类型:

ts
type TupleToUnion<T extends readonly any[]> = T[number]

type Union = TupleToUnion<[string, number, boolean]>

Unionstring | number | boolean。实现原理是利用数字索引访问类型——T[number] 会获取元组中所有数字索引位置的值类型并联合。

另一种基于递归的实现方式,帮助理解其机制:

ts
type TupleToUnionRecursive<T extends readonly any[]> = T extends [infer First, ...infer Rest]
  ? First | TupleToUnionRecursive<Rest>
  : never

UnionToIntersection

将联合类型转换为交叉类型——这是类型体操中最经典的技巧之一:

ts
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void
  ? I
  : never

type Intersection = UnionToIntersection<{ a: string } | { b: number } | { c: boolean }>

Intersection{ a: string } & { b: number } & { c: boolean }

深度分析这个实现的两步机制:

第一步U extends any ? (x: U) => void : never。利用分布式条件类型,将联合类型 { a: string } | { b: number } 分布展开为 ((x: { a: string }) => void) | ((x: { b: number }) => void)——即函数联合类型。

第二步... extends (x: infer I) => void ? I : never。这一步将函数联合类型进行推断。infer I 出现在函数参数位置(逆变位置),当对函数联合类型的参数进行推断时,根据逆变性质,多个候选类型会被推断为交叉类型。这就是联合转交叉的核心机制。

IsEqual

精确判断两个类型是否完全相等:

ts
type IsEqual<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
  ? true
  : false

type Test1 = IsEqual<string, string>
type Test2 = IsEqual<string, number>
type Test3 = IsEqual<{ a: string }, { a: string }>
type Test4 = IsEqual<any, unknown>
type Test5 = IsEqual<never, never>
type Test6 = IsEqual<true, boolean>

Test1trueTest2falseTest3trueTest4falseTest5trueTest6false

这个实现利用了 TypeScript 编译器内部的一个特性:当比较两个泛型条件类型时,编译器会检查其条件类型是否结构等价<T>() => T extends A ? 1 : 2<T>() => T extends B ? 1 : 2 仅在 AB 完全相同时才被视为兼容。这比简单的 A extends B ? B extends A ? true : false : false 更精确,因为后者无法区分 any 和其他类型。

其他实战类型

将联合类型转换为元组(利用函数重载的特性):

ts
type UnionToTuple<T> = UnionToIntersection<
  T extends any ? () => T : never
> extends () => infer R
  ? [...UnionToTuple<Exclude<T, R>>, R]
  : []

type Tuple = UnionToTuple<'a' | 'b' | 'c'>

Tuple['a', 'b', 'c'](顺序可能因编译器内部实现而异)。

获取联合类型的最后一个成员:

ts
type LastOfUnion<T> = UnionToIntersection<
  T extends any ? () => T : never
> extends () => infer R
  ? R
  : never

type Last = LastOfUnion<'a' | 'b' | 'c'>

Last'c'。原理是将联合转为函数交叉类型(等效于函数重载),然后 infer 推断时取最后一个重载的返回类型。


协变与逆变

协变(Covariance)与逆变(Contravariance)描述的是类型间的子类型关系在复合类型构造过程中如何传递。这是类型系统理论的核心概念,直接影响 TypeScript 中函数赋值、泛型兼容性等行为。

基本概念

假设 Animal 是父类型,Dog 是子类型(Dog extends Animal),则:

  • 协变(Covariant)F<Dog>F<Animal> 的子类型——子类型关系保持方向
  • 逆变(Contravariant)F<Animal>F<Dog> 的子类型——子类型关系反转方向
  • 不变(Invariant):二者之间没有子类型关系
  • 双变(Bivariant):两个方向都成立
ts
class Animal {
  name: string = ''
}

class Dog extends Animal {
  breed: string = ''
}

class Greyhound extends Dog {
  speed: number = 0
}

返回值协变

函数返回值处于协变位置——如果 Dog extends Animal,那么 () => Dog 可赋值给 () => Animal

ts
type CovariantProducer<T> = () => T

let animalProducer: CovariantProducer<Animal>
let dogProducer: CovariantProducer<Dog> = () => new Dog()

animalProducer = dogProducer

这是安全的:期望返回 Animal 的地方,返回一个更具体的 Dog 不会破坏任何约定——Dog 拥有 Animal 的所有属性,调用方可以安全地使用。

函数参数逆变

函数参数处于逆变位置——如果 Dog extends Animal,那么 (x: Animal) => void 可赋值给 (x: Dog) => void

ts
type ContravariantConsumer<T> = (item: T) => void

let animalConsumer: ContravariantConsumer<Animal> = (animal: Animal) => {
  console.log(animal.name)
}
let dogConsumer: ContravariantConsumer<Dog>

dogConsumer = animalConsumer

这也是安全的:期望处理 Dog 的函数位置,放置一个能处理所有 Animal 的函数——它一定能处理 Dog,因为 DogAnimal 的子类型。

strictFunctionTypes

TypeScript 在 2.6 版本引入了 strictFunctionTypes 编译选项(包含在 strict 中)。开启后,函数参数类型检查从双变变为逆变

ts
type Handler<T> = (event: T) => void

let animalHandler: Handler<Animal> = (a: Animal) => console.log(a.name)
let dogHandler: Handler<Dog> = (d: Dog) => console.log(d.breed)

animalHandler = dogHandler

开启 strictFunctionTypes 时,最后一行会报错:Type 'Handler<Dog>' is not assignable to type 'Handler<Animal>'。因为 dogHandler 需要访问 breed 属性,但传入的可能只是一个 Animal,没有 breed

需要注意的是,strictFunctionTypes 只对以函数类型语法(x: T) => void)声明的函数生效。以方法语法method(x: T): void)声明的函数仍然保持双变,这是为了兼容 Array<T> 等内置类型的方法:

ts
interface Comparer<T> {
  compare(a: T, b: T): number
}

let animalComparer: Comparer<Animal> = {
  compare: (a, b) => a.name.localeCompare(b.name)
}

let dogComparer: Comparer<Dog> = animalComparer

方法语法下这段代码不会报错,即使在 strictFunctionTypes 开启时。

协变与逆变在泛型中的应用

理解协变逆变对泛型参数推断至关重要——特别是在 infer 关键字的使用中:

ts
type CovariantInfer<T> = T extends { value: infer U } ? U : never
type ContravariantInfer<T> = T extends { handler: (x: infer U) => void } ? U : never

type CV = CovariantInfer<{ value: string } | { value: number }>
type CTV = ContravariantInfer<{ handler: (x: string) => void } | { handler: (x: number) => void }>

CVstring | number(协变位置推断出联合),CTVnever(逆变位置推断出 string & number,即 never)。


类型守卫

类型守卫(Type Guards)是 TypeScript 中收窄(Narrowing) 类型的机制。当代码在运行时进行类型检查后,TypeScript 的控制流分析(Control Flow Analysis)能够在后续分支中自动收窄变量的类型。

typeof 守卫

typeof 操作符可以守卫原始类型:

ts
function padLeft(value: string, padding: string | number): string {
  if (typeof padding === 'number') {
    return ' '.repeat(padding) + value
  }
  return padding + value
}

if 分支中,padding 的类型从 string | number 收窄为 number;在 else 分支中自动收窄为 string

typeof 能识别的类型有限:"string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"

instanceof 守卫

instanceof 用于检查原型链,适用于类实例的收窄:

ts
class ApiError {
  statusCode: number
  constructor(statusCode: number) {
    this.statusCode = statusCode
  }
}

class NetworkError {
  endpoint: string
  constructor(endpoint: string) {
    this.endpoint = endpoint
  }
}

function handleError(error: ApiError | NetworkError) {
  if (error instanceof ApiError) {
    console.log(error.statusCode)
  } else {
    console.log(error.endpoint)
  }
}

in 守卫

in 操作符检查属性是否存在于对象中,适用于区分具有不同属性的对象类型:

ts
interface Fish {
  swim: () => void
}

interface Bird {
  fly: () => void
}

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

in 守卫在联合类型中特别有用——TypeScript 会根据属性的存在性将联合类型收窄到包含该属性的成员。

is 类型谓词(自定义守卫)

当内置的守卫机制不够用时,可以通过类型谓词(Type Predicate) 定义自定义的守卫函数。返回类型声明为 paramName is Type

ts
interface Cat {
  meow: () => void
  purr: () => void
}

interface Dog {
  bark: () => void
  fetch: () => void
}

function isCat(animal: Cat | Dog): animal is Cat {
  return 'meow' in animal
}

function interact(animal: Cat | Dog) {
  if (isCat(animal)) {
    animal.purr()
  } else {
    animal.fetch()
  }
}

animal is Cat 告诉 TypeScript:当此函数返回 true 时,将参数的类型收窄为 Cat

自定义守卫在数组过滤中的应用:

ts
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'triangle'; base: number; height: number }

function isCircle(shape: Shape): shape is Extract<Shape, { kind: 'circle' }> {
  return shape.kind === 'circle'
}

const shapes: Shape[] = [
  { kind: 'circle', radius: 5 },
  { kind: 'square', side: 10 },
  { kind: 'circle', radius: 3 }
]

const circles = shapes.filter(isCircle)

circles 的类型被正确推断为 { kind: 'circle'; radius: number }[]。如果不使用类型谓词,filter 返回的类型仍然是 Shape[],因为普通的布尔返回值无法传递类型信息。

可辨识联合(Discriminated Unions)

可辨识联合是一种结合了字面量类型和联合类型的模式,配合 switch 语句可以实现穷举的类型收窄:

ts
type Action =
  | { type: 'INCREMENT'; payload: number }
  | { type: 'DECREMENT'; payload: number }
  | { type: 'RESET' }

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      return state + action.payload
    case 'DECREMENT':
      return state - action.payload
    case 'RESET':
      return 0
  }
}

在每个 case 分支中,action 的类型被收窄到对应的联合成员。编译器能够识别 action.type判别属性(Discriminant Property)——一个在每个联合成员中都具有不同字面量类型的属性。

穷举性检查——利用 never 确保所有分支都被处理:

ts
function assertNever(x: never): never {
  throw new Error('Unexpected value: ' + x)
}

function reducer2(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      return state + action.payload
    case 'DECREMENT':
      return state - action.payload
    case 'RESET':
      return 0
    default:
      return assertNever(action)
  }
}

如果将来向 Action 联合中添加了新成员但忘记处理,default 分支中 action 的类型将不再是 neverassertNever 调用会产生编译错误,提醒开发者补充处理逻辑。


声明文件

声明文件(Declaration Files,.d.ts)是 TypeScript 类型系统与 JavaScript 生态之间的桥梁。它只包含类型信息而不包含运行时代码,告诉编译器某个 JavaScript 模块、全局变量或外部库的类型形状。

.d.ts 文件的本质

.d.ts 文件在编译时参与类型检查,但不会被编译为任何 JavaScript 输出。它是纯粹的类型元数据。TypeScript 编译器通过以下优先级查找声明文件:

  1. 项目内的 .d.ts 文件
  2. node_modules/@types 下的声明包
  3. tsconfig.jsontypeRootstypes 指定的路径
  4. 三斜线指令引用的文件

declare 关键字

declare 用于声明一个存在于运行时但类型系统尚不知道的值。它只是告诉编译器"相信我,这个东西在运行时是存在的"。

ts
declare const API_BASE_URL: string

declare function greet(name: string): void

declare class EventEmitter {
  on(event: string, listener: (...args: any[]) => void): this
  emit(event: string, ...args: any[]): boolean
}

declare enum Direction {
  Up = 'UP',
  Down = 'DOWN',
  Left = 'LEFT',
  Right = 'RIGHT'
}

declare 声明不会产生任何 JavaScript 代码,它们在编译后完全消失。它的唯一作用是在类型空间中注册标识符和类型信息。

模块声明

当使用没有类型定义的第三方库时,可以通过 declare module 为其补充类型:

ts
declare module 'some-untyped-lib' {
  export function parse(input: string): Record<string, unknown>
  export function stringify(data: Record<string, unknown>): string
  export default class Client {
    constructor(config: { baseUrl: string; timeout?: number })
    request<T>(endpoint: string): Promise<T>
  }
}

模块声明也支持通配符匹配,常用于非 JS 资源的导入:

ts
declare module '*.css' {
  const classes: Record<string, string>
  export default classes
}

declare module '*.svg' {
  const content: string
  export default content
}

declare module '*.png' {
  const src: string
  export default src
}

模块增强(Module Augmentation)——扩展已有模块的类型:

ts
import { AxiosRequestConfig } from 'axios'

declare module 'axios' {
  interface AxiosRequestConfig {
    retryCount?: number
    retryDelay?: number
  }
}

通过在模块声明中重新声明同名接口,利用 TypeScript 的声明合并(Declaration Merging) 特性将新属性合并到原有接口中。

全局声明

declare global 用于在模块文件中向全局作用域注入类型:

ts
export {}

declare global {
  interface Window {
    __APP_CONFIG__: {
      apiUrl: string
      env: 'development' | 'staging' | 'production'
    }
  }

  interface Array<T> {
    toSorted(compareFn?: (a: T, b: T) => number): T[]
  }

  function __DEV_TOOLS_HOOK__(action: string): void
}

文件必须是模块(包含 importexport),declare global 才能生效。export {} 是最小的模块标记方式。全局声明修改了全局作用域中的类型,所有文件都能看到这些变更。

三斜线指令

三斜线指令是一种特殊的注释形式,用于声明文件之间的依赖关系。它必须出现在文件的最顶部(只能在其之前有其他三斜线指令或注释)。

ts
/// <reference path="./global.d.ts" />
/// <reference types="node" />
/// <reference lib="es2021" />
  • /// <reference path="..." /> 引用另一个声明文件,告诉编译器将该文件纳入编译。在模块系统出现之前,这是组织多文件项目的主要方式。
  • /// <reference types="..." /> 引用一个 @types 包的声明,等价于在 tsconfig.jsontypes 中添加该包。
  • /// <reference lib="..." /> 引用内置的 lib 声明文件,例如 es2021 会引入 lib.es2021.d.ts

在现代 TypeScript 项目中,三斜线指令的使用已经大幅减少,大多数场景可以通过 tsconfig.jsontypestypeRootslib 选项替代。但在编写 .d.ts 声明包时,三斜线指令仍然是声明对其他类型包依赖的标准方式。

声明文件的编写策略

为一个全局变量型库编写声明:

ts
declare namespace MyLib {
  interface Config {
    debug: boolean
    version: string
  }

  function init(config: Config): void
  function destroy(): void

  const VERSION: string
}

为一个既支持模块导入又挂载全局变量的 UMD 库编写声明:

ts
export as namespace MyUMDLib

export interface Options {
  timeout: number
  retries: number
}

export function create(options: Options): Instance

export interface Instance {
  execute<T>(task: () => T): Promise<T>
  cancel(): void
}

export as namespace MyUMDLib 表示当以脚本模式(非模块)使用时,库挂载为全局变量 MyUMDLib;当以模块方式导入时,正常使用 export 的内容。

声明文件中类型的组织原则:优先使用 interface 而非 typeinterface 支持声明合并,便于使用方扩展);避免使用 export default(命名导出更利于 IDE 自动补全和重构);使用 namespace 组织相关的类型,避免顶层命名空间污染。

用心学习,用代码说话 💻