过度类型化

2025 年 5 月 5 日

摘要

本文讨论了 TypeScript 类型系统中一个固有的权衡:更严格的类型更安全,但也常常更复杂。我描述了一种我称之为“过度类型化 (Hyper-Typing)”的现象,即某些库为了追求完美的类型安全,最终使用了过于复杂的类型,这些类型难以理解,会产生晦涩难懂的错误,甚至自相矛盾地导致不安全的工作方式。

我认为,更简单的类型,甚至是类型生成,通常会带来更实用和愉快的开发者体验,即使它们不够“完美”。

TypeScript 的类型系统是_渐进的_:在使用 TypeScript 描述 JavaScript 值时,你可以或多或少地精确,从说这个值可以是任何类型 (any),到绝对详细地描述该值在不同条件下的类型。

例如,考虑以下函数,该函数打印对象的属性(如果存在):

functionprintProperty(obj,key){
if(typeofobj==="object"&&obj!==null&&Object.hasOwn(obj,key)){
console.log(obj[key]);
}
}

我们可以用一种_宽松的_方式进行类型定义:

functionprintProperty(obj:any,key:string){
if(typeofobj==="object"&&obj!==null&&Object.hasOwn(obj,key)){
console.log(obj[key]);
}
}

但我们也可以更_严格_,要求 obj 是一个对象,并且 key 是它的一个属性:

functionprintProperty<Objextendsobject>(obj:Obj,key:keyofObj){
console.log(obj[key]);
}

这种严格性甚至允许我们删除函数内部的 if 检查,因为现在 TypeScript 可以在编译时保证 obj 始终具有属性 key

// 传入一个不存在的属性会报错。
printProperty({a:"a"},"b");
// 错误提示:类型“"b"”的参数不能赋给类型“"a"”的参数。

拥有这种额外的保证显然是可取的,但代价是使类型定义更加复杂。 在这里复杂性增加不多——类型仍然很容易理解——但它揭示了一种固有的权衡。 我们应该在哪里划清界限?

过度类型化 (Hyper-Typing)

最近,我尝试了一些库,它们为了追求完美的类型安全,使其类型定义过于复杂,在我看来几乎无法使用。 我将这种方法称为_过度类型化 (hyper-typing)_,我担心它正在成为 TypeScript 生态系统中的一种趋势。

实际上,我明白为什么会这样。我自己也经常是一个_过度类型化者 (hyper-typer)_! 这是一个滑坡:“如果我添加这个类型约束,那么调用者将在这种特定情况下收到错误”。 “我可以让这个函数在这里推断出这个类型,以便调用者在那里得到正确的类型提示”。

滑坡的底部是你达到了一个可以工作的地方,而且对于调用者来说也可能看起来不错——在顺利的情况下。 然而,最终得到的类型是一个复杂的烂摊子,并且当调用者偏离顺利路径时产生的编译错误是一堵难以理解的文本墙。

一个例子:TanStack Form

TanStack Form 是表单库中的新秀。 它大力推动类型安全,承诺为“更流畅的开发体验”提供“一流的 TypeScript 支持和出色的自动完成功能”。

该库完成的工作确实令人印象深刻。 只需提供表单的默认值即可:现在,对于你定义的每个表单字段——无论嵌套有多深——在读取或写入其值、进行验证等操作时,你都会获得正确的类型。

但是不要看香肠是怎么做出来的:你甚至无法理解它。 以 TanStack Form 文档中的简单示例 为例。 使用交互式沙箱并尝试检查字段的 meta 属性(或任何其他库值)的形状。 你会看到:

TanStack form 字段的 meta 属性的类型定义。

FieldMeta 类型有 17 个 (!) 泛型参数,是两种类型的交集——每种类型都采用相同的 17 个泛型——你最终可以在那里找到其属性的定义。

公平地说,在重新格式化类型定义文件并盯着它看了几分钟后,我确实开始理解发生了什么。 特别是对于 FieldMeta 类型,没有什么太晦涩难懂的,但我忍不住觉得,这种无疑聪明而准确的类型定义实际上并没有_帮助_我作为该库的用户。

过度类型化的缺点

TanStack Form 只是一个过度类型化的库的例子,但正如我所说,最近我遇到了其他遵循类似方法并给我留下类似问题的库:

折衷方案

在碰壁几次过度类型化的东西后,我现在可以说我更喜欢不太准确、不太安全的库。 它们可能更笨; 我可能需要显式定义一个_在技术上_可以推断出的类型。 但实际情况是,我发现使用它们总体上更令人愉快,并且最终得到的代码更易于理解和维护。

我发现另一种更令人愉快的方法是拥有一个单独的构建步骤,该步骤_生成_类型——例如,从模式定义中生成类型。 在互联网的某些角落,我看到它被描绘成终极的 DX 原罪,但在不止一次的情况下,我实际上发现它运行得非常好。 例如,用于构建静态网站的 Astro framework 为你的内容集合生成类型的方式简直令人愉快。 我真的希望有更多的工具效仿它的脚步。