原文链接:wolfgirl.dev/blog/2025-1…
发布时间:2025年10月22日 22:43:18
我看到qntm的帖子,想起自己有个实现类似想法的代码示例。随后我将这个示例扩展成一份(可能不完整的)列表,展示了在TypeScript中将任意类型¹进行类型转换的各种方法:
约定:as 运算符
啊,老掉牙的:
const cast = <A, B,>(a: A): B => a as unknown as B;
我们不能直接将A替换为B,因为TypeScript足够聪明,至少会就此发出警告。这条错误信息还明确指出:
如果这是故意的,请先将表达式转换为'未知'。
所以我们直接这么做就行啦 :3
若从类型理论角度出发,这事早就尘埃落定了——我们已获得最明确无误的不健全性证明,收拾东西回家吧。
但若无法使用as呢?我们还能在两个完全无关的类型间穿梭吗?
非常规用法 1:is 运算符
is 通常用于与 TypeScript 的流式类型系统进行交互,帮助其准确理解布尔函数的返回值含义。例如:
const notUndefined1 = <A,>(a: A | undefined): boolean => a !== undefined;
const notUndefined2 = <A,>(a: A | undefined): a is A => a !== undefined;
const maybeNumber0: number | undefined = someExternalFunction();
if (maybeNumber0 !== undefined) return;
// Thanks to flow-typing, Typescript knows that `maybeNumber0: number`
// if we get here.
const maybeNumber1 = someExternalFunction();
if (notUndefined1(maybeNumber1)) return;
// However, Typescript cannot infer flow from ordinary functions;
// At this point, it still thinks `maybeNumber1: number | undefined`
const maybeNumber2 = someExternalFunction();
if (notUndefined2(maybeNumber2)) return;
// The `is` annotation has the exact same `boolean` value at runtime,
// but provides extra information to the compiler, so Typescript can know
// that `maybeNumber2: number` if we get here.
然而,这算是常规输入系统之外的一个逃生通道,我们可以滥用它来告诉编译器我们想要的任何内容:
const badDetector = <A, B,>(a: A): B => {
const detector = (_ab: A | B): _ab is B => true;
if (detector(a)) return a;
throw new Error("unreachable");
};
Typescript 不会(也无法!)检查函数体是否真正实现了断言所描述的行为。因此我们可以故意编写一个错误的函数!(或者无意中引入一个相当微妙的错误。)
非常规2:跨越边界的变异
此转换需要一个"种子"值 b: B,才能将 a: A 转换为 B,但请注意:如果我们不谨慎处理对象的变异方式,此类情况可能频繁出现。
const mutation = <A, B,>(a: A, b: B): B => {
const mutate = (obj: { field: A | B }): void => {
obj.field = a;
};
const obj = { field: b };
mutate(obj);
return obj.field;
};
我把这个给一位类型论的朋友看,他们的反应是:
兄弟,这类型系统简直烂透了方差问题太难搞了 xd xd xd
他们所指的是,当目标filed可变时,从 { field: B } 强制转换为 { field: A | B } 是不安全的;若允许此操作,就会出现此处展示的行为。要确保安全,需要使用 { readonly field: A | B },这样就能阻止变异操作。
另一种理解方式是:TypeScript目前无法在函数执行后对obj.field的转换/赋值进行"类型推导"(这可能是刻意为之,因为这会使类型系统更复杂并限制某些实用模式)。内联obj.field = a;能让我们捕捉到这种情况,但分析无法跨越函数边界。
非传统做法三:通过结构化类型进行转化
TypeScript采用结构化类型系统。这意味着当我们遇到obj: { field: string }时,仅能确定存在obj.field: string这一字段。TypeScript完全不关心obj是否存在其他字段,而这恰恰是结构化类型的最大优势:我们可自由"向上转换"至限制更少的类型(即字段更少),且无需改变运行时表示形式。
此类向上转换的弊端在于:某些操作(如 Object.values/展开运算符)仅在拥有完整字段列表时才能正确类型化,当存在额外字段时其类型假设将被破坏:
const loopSmuggling = <A, B,>(a: A, b: B): B => {
const objAB = { fieldA: a, fieldB: b };
const objB: { fieldB: B } = objAB;
for (const field of Object.values(objB)) {
// Object.values believes all fields have type `B`,
// but actually `fieldA` is first in iteration order.
return field;
}
throw new Error("unreachable");
};
const spreadSmuggling = <A, B,>(a: A, b: B): B => {
const objA = { field: a };
const obj: {} = objA;
const objB = { field: b, ...obj };
// `objB.field` has been overwritten by the spread,
// but Typescript doesn't know that.
return objB.field;
};
这些转换与(2)存在相同的限制,即我们需要一个"种子"值 b: B 才能通过类型检查。然而这堪称双重打击,因为试图通过使用...展开语法复制对象来规避(2)时,很可能在其他地方直接撞上这个限制。
非常规会议4:| void极其邪恶
这个绝对是最不寻常的用法;如果你用过一段时间TypeScript,其他用法你大概都见识过了,但这个几乎不会出现,因为它简直是种"搞这个干嘛"的操作。不过我在公司代码库里确实见过(发现后立刻删掉了),所以也不是完全不可能遇到。
总之就是这样:
const orVoid = <A, B,>(a: A): B => {
const outer = (inner: () => B | void): B => {
const b = inner();
if (b) return b;
throw new Error("falsy");
};
const returnsA = (): A => a;
const voidSmuggled: () => void = returnsA;
return outer(voidSmuggled);
};
这结合了几个有趣的点。在TypeScript中,void主要作为函数的返回值出现,表示"我不在乎这个函数返回什么,因为我根本不会使用它"。正因如此,任何函数(包括我们的() => A函数)都可以安全地强制转换为() => void。通常这种转换是安全的,因为一旦得到() => void类型,我们既无法将其输出赋值给变量,也无法直接将值类型化为void——毕竟它是个特殊类型。
然而void仍可参与类型组合,例如B | void。由于函数的返回类型具有协变性,() => void可安全转换为() => B | void。而事实证明,我们可以将这种 B | void 的返回类型赋值给变量!
如果 void 本意是允许直接赋值的类型,它应该表现得更像 any 或 unknown。但事实并非如此,因此它表现得像一个假值类型——因为普通返回 void 的函数在运行时实际上会返回 undefined。这正是我们能实现 if (b) return b;(这与检查 b 的真实类型不同!)且仍通过类型检查的原因。
遗憾的是,这意味着该转换仅适用于真值 a。不过我认为这并非大问题,其酷炫程度足以弥补这个限制 :3
这真的重要吗?
是的,但情况很复杂。
一方面,TypeScript显然只是为JavaScript添加类型的"尽力而为"之作,而且做得相当出色。只要正确使用,这些问题就不会出现,你的代码确实比使用原始JavaScript安全得多。
另一方面,所有这些"非规范"设计都是真正的陷阱,稍有不慎就会意外引入代码库的安全隐患。仅需某处存在微小缺陷,便可能导致整片代码区域布满漏洞。虽然我们能手动规避这些模式,但自动化解决方案始终更擅长捕捉此类问题。
我们能做些什么呢?
简而言之:使用 typescript-eslint²。虽然单独的 TypeScript³ 或 ESLint⁴ 都不具备足够的规则来检测这些问题,但 typescript-eslint 规则集包含了诸如 @typescript-eslint/prefer-readonly-parameter-types(防止 (2))、 @typescript-eslint/no-invalid-void-type(防止(4))、@typescript-eslint/no-unnecessary-type-parameters(通过强制unknown类型传播来防止其余问题)。遗憾的是这些规则均需手动启用,且TypeScript+eslint+typescript-eslint的组合总要折腾一番才能正常工作。
总之,希望这些示例足以说服你在未来的TypeScript项目中采用更严格的代码检查 :3
脚注
-
好吧,我可能在"任意"这个词上稍微夸张了点 :P (1)和(2)确实适用于所有情况,但(3)有时对undefined无效,而(4)仅当待转换的a为真值时才有效。不过我觉得这已经足够说明问题了。↩