TypeScript 中非常规的类型转换方式
Unconventional Ways to Cast in TypeScript

原始链接: https://wolfgirl.dev/blog/2025-10-22-4-unconventional-ways-to-cast-in-typescript/

## TypeScript 类型转换的“非常规用法” – 总结 本文探讨了绕过 TypeScript 类型安全性的几种令人惊讶的方法,展示了其类型系统的局限性。虽然 TypeScript 旨在为 JavaScript 添加类型,但这些方法揭示了开发者可能遇到的潜在“陷阱”。 核心技术涉及利用漏洞:使用 `as unknown as` 操作符,滥用 `is` 操作符进行错误的类型断言,利用可变对象属性,结构化类型的灵活性,以及 `| void` 类型的特殊行为。这些允许在不相关类型之间进行转换,通常需要目标类型的“种子”值。 作者强调,这些不一定是 TypeScript 中的 *错误*,而是设计选择的后果——例如允许可变对象强制转换或 `void` 的特定作用。虽然 TypeScript 通常可以提高代码安全性,但这些非常规用法可能会引入微妙且难以检测的错误。 推荐的解决方案是使用严格的 linting 规则,通过 `typescript-eslint`,特别是像 `@typescript-eslint/prefer-readonly-parameter-types` 和 `@typescript-eslint/no-invalid-void-type` 这样的规则,来主动防止这些不安全的模式。最终,自动化检测对于维护大型项目的类型安全至关重要。

## 非常规 TypeScript 类型转换技巧 最近 Hacker News 上的一场讨论强调了 TypeScript 中一些不寻常,有时甚至是可疑的类型转换技巧。对话始于一个具体案例,其中使用 `as ['foo']` 进行转换以满足一个函数,该函数期望特定对象类型的键数组,从而绕过了导入的需求。 用户们争论这些方法的优点,一些人提倡更清晰的类型注解或 linting 规则以防止类型转换。另一些人指出 `as unknown as B` 在复杂的类型操作中很有用,尤其是在处理流畅 API 或外部数据时,但警告不要过度使用,因为可能会导致未来类型不兼容的问题。 讨论还涉及滥用 `void` 的危险以及使用 `satisfies` 进行类型收窄的潜在好处。一个共同的主题是,TypeScript 的安全性依赖于严格的使用和设置——启用像严格类型检查和 ESLint 预设这样的功能可以显著提高代码的可靠性。最终,这篇文章引发了关于在类型安全与实用性和代码可读性之间取得平衡的争论。
相关文章

原文

I saw a post by qntm and remembered I had a playground with a similar idea. I then expanded that playground into a (probably non-exhaustive) list of ways to cast between arbitrary1 types in Typescript:

Convention: The as Operator

Ah, good ol' as:

const cast = <A, B,>(a: A): B => a as unknown as B;

We can't just directly do a as B because Typescript is smart enough to warn us about that, at least. That very same error message also says,

If this was intentional, convert the expression to 'unknown' first.

So we can just do that :3

If we were approaching this from a type theoretic perspective, it's already done & dusted, we have the most cut-and-dry demonstration of unsoundness, pack it up go home.

But, what if we couldn't use as? Can we still get between two completely unrelated types?

Unconvention 1: The is Operator

is is commonly used for for interfacing with Typescript's flow-typing system, helping it figure out what exactly the return value of a boolean function means. For example:

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.

However, is is sort of an escape hatch outside the regular typing system, and we can abuse it to tell the compiler whatever we want:

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 doesn't (and can't!) check that the function body is actually doing what the is assertion says. So we can just write a bad one on purpose! (Or on accident, introducing quite a subtle bug.)

Unconvention 2: Mutation Across Boundaries

This cast requires a "seed" value b: B in order to be able to cast a: A to B, but make no mistake: this sort of thing can come up fairly often if we're not careful about how we mutate objects.

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;
};

I showed this to a type theory friend and their reaction was:

bruh ts type system mega fails Variance is hard xd xd xd

What they meant by that was, the coercion from { field: B } to { field: A | B } is unsafe when the destination field is mutable; if we allow it, we get exactly the behavior shown here. To make it safe we'd need { readonly field: A | B }, which then prevents the mutation.

Another way of thinking about this is, Typescript currently has no way to "flow" the cast of/assignment to obj.field after the function runs. (Potentially on purpose, because that would make the type system even more complex & limit certain useful patterns.) Inlining the obj.field = a; allows us to catch this, but the analysis does not go across function boundaries.

Unconvention 3: Smuggling Through Structural Typing

Typescript is structurally typed. This means that, if we have an obj: { field: string }, all we know is that there exists an obj.field: string. Typescript doesn't care at all if obj has other fields, and in fact this is the biggest advantage of structural typing: we can freely "upcast" to less restrictive types (i.e. fewer fields) without having to change runtime representations.

The downside of this sort of upcasting is that, some operations like Object.values/the spread operator are only properly typed when they have a complete list of fields, and have their assumptions violated when extra fields are in the mix:

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;
};

These casts have the same restriction as (2), in that we require a "seed" value b: B in order to make it typecheck. Still, it's a bit of a double-whammy, because trying to avoid (2) by copying objects with a ... spread can make you run smack-dab into this one elsewhere.

Unconvention 4: | void is Very Bad

This one's by far the most unconventional; the rest you're probably aware of if you've worked with Typescript for a while, but this one hardly ever comes up because it's such a "why even do this" kinda deal. Still, I have seen it in my work's codebase (and immediately excised it once I realized), so it's not impossible to come across.

Anyways here it is:

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);
};

This is a combination of a few interesting things. For Typescript, void is primarily seen as a function's return value, indicating "I don't care what this function returns because I'm not going to use it". This is why any function, including our () => A one, can be safely coerced to () => void. Usually, this is safe, because once we have a () => void, we really can't assign its output to a variable, nor can we directly type a value as void; it's a very special type after all.

However, void can still participate in type combinations like B | void. And, because functions are covariant in their return type, () => void can be safely coerced to () => B | void. And, as it turns out, we can assign that B | void return type to a variable!

If void were meant to be assigned directly, it should behave something more like any or unknown. But it's not, so instead it behaves like a falsy type, because a normal void-returning function actually returns undefined at runtime. This is how we're able to if (b) return b; (which is not the same as checking b's true type!) & still have everything typecheck.

Unfortunately, that means this cast only works for truthy a. But that's not too much of an issue I think, the Cool Factor outweighs this limitation :3

Does This Even Matter?

Yes, but it's complicated.

On the one hand, Typescript is clearly just a "best effort" at adding types to Javascript, and it does a darn good job at that. If you're holding it right, these things don't come up, and your code genuinely is much much safer than if you used raw Javascript.

On the other hand, all these "unconventions" are real footguns one can stumble into & unintentionally introduce unsafety into your codebase. It only takes a little bit of unsoundness in one place to render entire swaths buggy. We can do our best to avoid these patterns manually, but an automated solution will always be better at catching them.

What Can We Do About This?

TL;DR use typescript-eslint2. While neither Typescript3 nor Eslint4 on their own come with enough rules to detect any of these, the typescript-eslint ruleset includes things like @typescript-eslint/prefer-readonly-parameter-types (prevents (2)), @typescript-eslint/no-invalid-void-type (prevents (4)), & @typescript-eslint/no-unnecessary-type-parameters (prevents the rest by making the unknown viral). Unfortunately, all of them are opt-in, and Typescript + eslint + typescript-eslint always requires a fair bit of mucking about to get working.

Anyways, I hope these examples are enough to convince you to use more aggressive linting on your Typescript projects in the future :3

联系我们 contact @ memedata.com