Typescript “casts” are not casts
At Redgate we recently ran an internal typescript training course, and I wanted to highlight a common misconception I saw a couple of times.
To explain what I mean, let’s compare some similar-looking code in C# and typescript. In C#, we can use the as
operator (sometimes called a “safe” cast) to check the type of an object:
public void HandleEvent(object sender, Event event) {
var ourEvent = event as OurCustomEventType;
if (ourEvent != null) {
// ... handle event
}
}
Alternatively, we can also use a cast expression (or “explicit” cast) to throw an InvalidCastException
if the cast fails:
var publishedDate = (DateTime)metadata["publishedDate"];
Console.WriteLine("Document published on " +
publishedDate.ToString("MMMM dd, yyyy"));
The important part is that these casts happen at runtime: the C# compiler emits instructions to:
- check whether the variable has the correct type,
- perform any appropriate conversion operations (eg boxing or unboxing an object, converting one type of number to another, or running user-defined conversions), and
- handle cast failures and respond appropriately (returning
null
or throwing anInvalidCastException
).
Typescript, however, does none of these things. If you’re coming to typescript from another language, you may be surprised to find out that your intuition about casts doesn’t apply here.
While typescript has syntax that looks like casts, these are actually “type assertions” instead. Let’s take the first example, and write something like it in typescript:
const handleEvent = (sender: object, event: Event) => {
const mouseMove = event as MouseEvent;
console.log(`Mouse moved to ${mouseMove.x},${mouseMove.y}`);
}
This looks very similar to the first C# example — indeed, the syntax is almost identical. But if we take a look at the typescript playground, we can see that our code actually compiles to the following javascript:
const mouseMove = event;
and the type assertion has disappeared completely. Type assertions always succeed. They’re a way for you to tell the typescript compiler “Suppress errors here, I know what I’m doing” which can be useful, but is more dangerous than you might expect. If you’ve cast to a type with more properties than the object actually has, then any later code will assume those properties exist — and you might see errors caused by impossible-looking undefined
values.
Are type assertions completely unsafe? There is some minimal type checking for “unrelated” types. If the source type has nothing in common with the target type, then typescript will complain:
interface Cat {
numLives: 9;
}interface Dog {
bark: () => void;
}const animal = { bark: () => {} } as Cat
// ^ fails with an error: Conversion of type '{ bark: () => void; }' to type 'Cat' may be a mistake because neither type sufficiently overlaps with the other.
However, if we tried to compile { } as Cat
, then typescript wouldn’t complain — because { }
is a subset of the Cat
interface.
So when should you use type assertions? I’d say the answer is as little as possible. Here are a couple of situations where they can be necessary:
- When you genuinely know better than the compiler
For example,Object.keys
andObject.entries
return stringly-typed keys instead ofkeyof typeof obj
, because any object with more properties can satisfy that interface. But if that’s unlikely to happen in your specific code, then you can cast it back to the more specific string union type to get better type safety later on:
for (const [key, value] of Object.entries(dataRow)) {
renderCell(getHeaderIcon(key as keyof ModelType), value);
}
- When handling external data
If you’re receiving an external object from a web request orJSON.parse()
, you probably already know what shape you expect the data to be in. If possible, you should implement type guards (or use a library likeio-ts
which automates a lot of runtime checking). But this may be overkill for many projects, and it’s a lot easier to just stick in a type assertion — especially since it’s an improvement over propagating theany
type! - When mocking large objects in a test
In test code, we can sometimes afford to be a little sloppier with types, because we’re not risking production code breaking, and we really care whether the test passes or not. For example, a mockedfetch
call returns aResponse
object with a couple dozen properties but we might only care about calling.json()
. In that case we need the type assertion just to satisfy typescript, even though we know it’s incorrect:
const result = { success: false, error: "Something exploded!" };
window.fetch = jest.fn().mockReturnValueOnce(
Promise.resolve({
json: async () => result
} as Response)
);
So, to sum up: typescript type assertions may be more dangerous than you may think, and you should avoid them if possible. But sometimes they turn out to be necessary! The important thing, as always, is to be properly informed about how they work and to make deliberate decisions when writing code.