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
Footnotes
-
OK, I may have over-exaggerated on the "arbitrary" part a little :P (1) and (2) do in fact work for everything, but (3) doesn't work for
undefined
sometimes and (4) only works if thea
to be cast is truthy. Still, it's a good enough demonstration I think. ↩