Site Logo

泛型函数中的类型收缩

2025年9月5日 · 3563

作为 TypeScript 的开发者,类型收缩我们已经很熟悉了, 用起来也很自然。

但是,很多开发者其实不知道,在泛型的世界里,类型收缩曾经是个老大难问题。今天,我们就来聊聊 TypeScript 泛型中类型收缩的演进历程。

首先快速回顾一下什么是类型收缩: 所谓“收缩”(Narrowing),就是利用特定的运行时检查来缩小变量的类型范围。当我们在 if 语句中使用 typeofinstanceof===或者类型谓词(Type Predicate)时,TypeScript 编译器会分析代码的控制流,将宽泛的类型(如 string | number)精确到具体的类型(如 string)。

一个简单的例子:

function padLeft(padding: number | string, input: string) {
  if (typeof padding === 'number') {
    // 在这里,padding 被收缩为 number
    return ' '.repeat(padding) + input
  }
  // 在这里,padding 被收缩为 string
  return padding + input
}

这是一个普通函数,接收联合类型参数 padding。通过 typeof 检查,TypeScript 能准确推断出每个分支中 padding 的具体类型,一切都很完美。

按照 TypeScript 官方文档的说法,这种基于可达性的代码分析称为控制流分析(control flow analysis),TypeScript 正是使用这种流分析在遇到类型守卫和赋值时缩小类型范围。

但有意思的是,在泛型的世界里,这种看似理所当然的类型收缩,却经历了一段颇为曲折的发展过程。

泛型收缩的两次大跃进

如今的 TypeScript 中泛型的类型收缩已经处理得不错, 能覆盖大部分常见场景 。但这也是一步步改过来的,有两个重要的里程碑。

1. TS 2.4 与 PR #15576:支持排除 null 和 undefined

在 2017 年前后,TypeScript 对泛型变量的控制流分析能力还非常有限。当泛型参数的约束中包含 nullundefined 时,即使分支中明确做了判空检查,编译器依然无法确信泛型变量已经是非空类型了。

假设我们写了一个泛型函数,泛型参数 T 可能是 Item,也可能是 undefined。我们自然会想到用 if (obj) 来排除空值。但在 TS 2.4 之前,编译器是无法理解这种意图的,因为它认为泛型 T 是一个不可分割的整体。

type Item = {
  (): string
  x: string
}

function f1<T extends Item | undefined>(obj: T) {
  if (obj) {
    obj.x
    obj['x']
    obj()
    // 在 TS 2.4 之前,所有对泛型变量 obj 属性的访问都会报错
    // 并不是因为 obj 可能为 undefined,而是因为ts编译器无法通过控制流分析
    // 将“排除 undefined”的信息应用到泛型参数 T 上,
    // 泛型参数是一个整体,它的内部结构不可分析、不可拆解

    // 而在 TS 2.4 之后,obj 则会被正确收缩为 Item
  }
}

PR #15576 (Fix narrowing of generic types by type guards)解决了这个问题。它改进了控制流分析,允许在使用类型守卫时,正确地从泛型变量中剔除 null 或 undefined。

这是泛型收缩迈出的第一步:编译器不再僵化地守着泛型定义的边界,而是开始学会在控制流中动态地“剔除”泛型约束中的空值

2. TS 4.3 与 PR #43183:支持联合类型的收缩

空值问题解决了,但新问题又来了:如果泛型约束的是两个非空类型的联合呢?TypeScript 还能基于控制流推断出正确的类型吗?

在很长一段时间里,答案是不能。这正是 Issue #13995 描述的困境。

先看字面量联合类型的例子:

declare function takeA(val: 'A'): void
export function bounceAndTakeIfA<AB extends 'A' | 'B'>(value: AB): AB {
  if (value === 'A') {
    // ❌ TS 4.3 报错!
    // TS 认为 value 依然是宽泛的 'AB' 类型,无法赋值给 'A'
    takeA(value)
    return value
  } else {
    return value
  }
}

再看对象类型联合的例子:

type X = { a: string } | { b: string }

function func<T extends X>(value: T) {
  if ('a' in value) {
    const a = value.a // ❌ TS 4.3 之前报错:Property 'a' does not exist on type 'T'
    console.log('a', a)
  } else {
    const b = value.b // ❌ TS 4.3 之前报错:Property 'b' does not exist on type 'T'
    console.log('b', b)
  }
}

还有判别联合类型(discriminated union)的例子:

type C = { kind: 'a'; a: string } | { kind: 'b'; b: string }
function func<T extends C>(value: T) {
  if (value.kind === 'a') {
    value.a // ❌ TS 4.3 之前报错:Property 'a' does not exist on type 'T'.
  } else {
    value.b // ❌ TS 4.3 之前报错:Property 'b' does not exist on type 'T'.
  }
}

这些都非常反直觉——明明已经通过 ===in 或属性判断做了检查,为什么编译器还是认为类型不匹配?

有意思的是,对于某些场景,旧版本的 TypeScript 其实是有收缩能力的:

// 使用 typeof 做泛型的类型收缩
declare function takeString(val: string): void
export function bounceAndTakeIfString<T extends string | number>(value: T): T {
  if (typeof value === 'string') {
    takeString(value) // ✅ 即使在 TS 4.3 之前也能通过
    return value // value 被推断为 T & string
  } else {
    return value
  }
}

// 使用 instanceof 做泛型的类型收缩
class Dog {
  bark() {}
}
class Cat {
  meow() {}
}
function handleGeneric<T extends Dog | Cat>(animal: T) {
  if (animal instanceof Dog) {
    animal.bark() // ✅ 即使在 TS 4.3 之前也能通过
    //  animal 被推断为 T & Dog
  }
}

// 使用自定义类型守卫做泛型的类型收缩
interface Fish {
  swim(): void
}
interface Bird {
  fly(): void
}
type Pet = Fish | Bird

declare function getSmallPet(): Pet
function isFish(pet: Pet): pet is Fish {
  return (pet as Fish).swim !== undefined
}

let pet = getSmallPet()
if (isFish(pet)) {
  // 这里 pet 被收缩为 Fish
  pet.swim() //  ✅ 即使在 TS 4.3 之前也能通过
} else {
  // 这里 pet 被收缩为 Bird
  pet.fly() //  ✅ 即使在 TS 4.3 之前也能通过
}

核心原因在于:旧版本的 TypeScript 对泛型变量的分析策略不同

  • 对于 typeofinstanceof 或类型谓词(做加法): 当你使用这些检查时,编译器不需要深入分析泛型 T 的内部结构。它采用的是 “叠加”策略 ——既然变量通过了检查(比如 instanceof Date),那它肯定既符合 T 的约束,又拥有 Date 的特性。 因此,编译器会简单粗暴地将它们推断为交叉类型,例如 T & stringT & Date。这种“做加法”的操作(增加约束)在 TS 中一直处理得很好。

  • 对于 ===in(做减法): 当你检查 value === 'A' 时,情况就变了。编译器试图将类型从宽泛的 T 缩小到具体的 'A'。为了确保安全,编译器必须先“拆开”泛型 T,检查它的约束里是否真的包含 'A'。 但在旧版本中,泛型 T 被视为一个不透明的黑盒,编译器“无法”去看它的约束,因此无法确定 value 是否真的变成了 'A'

直到 2021 年,TS 4.3 通过 PR #43183(Contextual narrowing for generics)彻底修复了这个问题。

这个 PR 的核心是引入了 getNarrowableTypeForReference 函数。它的关键改进是:编译器学会了利用泛型的约束类型来辅助收缩,它会计算出 T 的约束类型在当前检查下的收缩结果,并将其作为变量的类型。

具体来说:对于 value: AB extends 'A' | 'B',在 if (value === 'A') 内部:

  • 旧版本:控制流分析直接作用在泛型 AB 上,但 AB 是不透明的,无法收缩。
  • 新版本:编译器会先获取 AB 的约束 'A' | 'B',然后将控制流应用于这个约束。因为 'A' | 'B' 收缩后是 'A',所以编译器判定在此分支下,value 可以被安全地视为 'A'

对于对象类型联合和判别联合类型也是同理,编译器现在都能正确地进行收缩了。

依然存在的隐秘角落

读到这里,你可能会觉得 TypeScript 的泛型收缩已经完美了。

但其实不然, 在面对更复杂的类型关系时, 泛型变量收缩依然受限。 比如:你对变量 A 的收缩只能确认 A 当前的值,但这无法反向缩小变量 A 对应的泛型参数的定义范围,因此依赖于该泛型参数的变量 B 自然无法同步收缩。 归根结底,这是因为:TypeScript 无法真正信任泛型函数内部的任何形式的类型收缩操作。

上述表述可能比较抽象,我们通过一个例子来帮助大家理解。

一个让无数 TS 开发者抓狂的例子

让我们来看一个经典场景:我们需要根据属性名key,返回对应的属性值value

type Person = {
  name: string
  age: number
  birthdate: Date
}

// 目标:根据 key 的类型,返回对应的 Person[key] 类型
function remapPerson<K extends keyof Person>(
  key: K,
  value: Person[K]
): Person[K] {
  if (key === 'birthdate') {
    // 🔍 此时:
    // 1. 变量 key 的类型被成功收缩为 'birthdate' 字面量类型。
    // 2. 逻辑上我们认为:既然 key 是 'birthdate',那 value 就应该是 Date,
    //    返回值也应该是 Date。

    console.log(value) // 鼠标悬停发现 value 依然是 string | number | Date

    // ❌ 报错:Type 'Date' is not assignable to type 'Person[K]'.
    return new Date()
  }

  return value
}

如果我们在 if 语句内部检查,会发现:

  • 变量 key(值层面):确实被收缩了,TypeScript 非常清楚在这个块级作用域内,key 的值就是 'birthdate'
  • 泛型参数 K并没有被收缩! 它依然是宽泛的 keyof Person

这就是问题的核心。控制流分析是基于变量的,而不是基于类型参数的。

当执行 if (key === 'birthdate') 时,编译器标记变量 key 在该作用域内为 'birthdate'。但是,泛型参数 K 依然是宽泛的, 并没有因为其对应的变量key发生了类型收缩而去收缩类型 。因此,返回类型 Person[K] 依然是一个未解析的泛型,它代表了 Person 中任意属性值的类型(string | number | Date)。显然,你不能把一个固定的 Date 类型,强行赋值给一个由泛型决定、可能会变成 string 或 number 的返回值类型

你可能会问:“为什么 TypeScript 在对变量 key 进行收缩的同时,不敢将泛型参数 K 也同时收缩呢?”

为什么编译器“不敢”收缩?

为了看清真相,我们构造一个符合 TypeScript 语法、但极具迷惑性的调用案例:

// 这是一个完全合法的调用!
// 泛型 K 被显式指定为联合类型: 'age' | 'birthdate'
remapPerson<'age' | 'birthdate'>(
  'birthdate', // ✅ 参数 key:符合 K 的约束
  100 // ✅ 参数 value:符合 Person[K] 的约束
  //    (因为 Person['age' | 'birthdate'] 即 number | Date,100 是合法的)
)

请注意,在这个调用中,key'birthdate',但 value 却是 100(数字)。这在联合类型的定义下是完全成立的。

现在,让我们回到函数内部。假设 TypeScript 真的像我们希望的那样,因为 if (key === 'birthdate') 就天真地认为泛型 K 也变成了 'birthdate',那么灾难就发生了:

function remapPerson<K extends keyof Person>(
  key: K,
  value: Person[K]
): Person[K] {
  if (key === 'birthdate') {
    // 💀 假设的灾难现场:
    // 如果 TS 认为 K 收缩成了 'birthdate',
    // 那么 value 的类型就会被错误地推断为 Person['birthdate'] (即 Date)。

    // 此时编译器会觉得这行代码完全没问题(因为它以为 value 是 Date):
    console.log(value.getFullYear())

    // 💥 但在运行时,程序直接崩溃!
    // 因为在这个特定的调用里,value 实际上是传进来的 100 (number)。
    // 数字没有 getFullYear 方法。

    return new Date()
  }

  return value
}

这就是“类型安全”的底线。

一旦 TypeScript 在这里妥协,我们就会面临开发者最不想看到的局面:编译期显示一切正常,上线后却因类型错误而崩溃。

所以,TypeScript 拒绝在 if (key === ...) 中收缩泛型 K 的根本原因就在于此: 变量 key 的特定值,只是泛型 K 可能性的一个子集。 即使在当前这条执行路径上 key 的值确定了,我们也不能推断泛型参数 K 在所有可能的调用场景下都随之缩小。为了防止那种极端但合法的调用导致运行时错误,编译器选择了最保守、最安全的策略。

解决方案:放弃抵抗,拥抱 as

既然知道了这是 TypeScript 刻意为之的设计限制(为了防御极端情况),我们就不要试图用奇技淫巧来规避这个问题了。

引用一句 TypeScript 社区的名言:

"If you like it, then you should have put an 'as' on it."

在这里使用 as 并不是代码写得烂,而是你作为开发者在行使你的权利——你拥有比编译器更多的上下文信息。你明确知道在这个具体的业务逻辑中,不会出现上述那种刁钻的联合类型调用。

function remapPerson<Key extends keyof Person>(
  key: Key,
  value: Person[Key]
): Person[Key] {
  if (key === 'birthdate') {
    // 既然 TS 无法理解这里的关联,我们手动帮它画上等号
    // 我们明确知道:在此分支下,返回值就是一个合法的 Person[Key]
    return new Date() as Person[Key]
  }

  return value
}

在泛型的世界里,与其和编译器无休止地搏斗,不如在理解了它的良苦用心后,优雅地补上一个 as。这才是成熟的 TypeScript 开发者应有的心智模型。

写在最后

聊了这么多,其实千言万语汇成一句话:TypeScript 很强,但它也有顾虑。

以前我们觉得泛型收窄失效是 TS 不够智能,但通过上文的那种极端例子,我们知道了,编译器并非全知全能,它必须在“推断的灵活性”与“运行时的安全性”之间做出取舍。 编译器有自己的苦衷——它宁愿报错,也不敢赌那万分之一的运行时崩溃风险。

在日常开发中,我们追求完美的类型推导,但在泛型函数的边界上,完美往往意味着极高的成本。

所以,建立正确的心智模型比死磕技巧更重要:

  1. TS 4.3 已经帮我们搞定了 90% 的泛型收窄。
  2. 剩下那 10% 涉及泛型参数联动的场景,放心大胆地用 as 吧。

只要你心里清楚自己在做什么,那个 as 就不是一种妥协,而是一种自信。