Hang out on the internet much and you'll hear gripes about how TypeScript isn't "sound," and that this makes it a poor choice of language. In this post, I'll explain what this means and walk through the sources of unsoundness in TypeScript. Rest assured, TypeScript is a great language and it's never a good idea to listen to people on the internet!
Roughly speaking, a language is "sound" if the static type of every symbol is guaranteed to be compatible with its runtime value.
Here's a simple example of a sound type:
TypeScript infers a static type of
x, and this is sound: whatever value
Math.random() returns at runtime, it will be a
number. This doesn't mean that
x could be any
number at runtime: a more precise type would be the half-open interval
[0, 1), but TypeScript has no way to express this.
number is good enough. If you remember the famous statistics dartboard, soundness is more about accuracy than precision.
Here's an example of unsoundness in TypeScript:
The static type of
x is inferred as
number, but at runtime its value is
undefined, which is not a
number. So this is unsound.
That being said, unsoundness can lead to crashes and other problems at runtime, so it's a good idea to understand the ways that it can arise.
Here are the seven sources of unsoundness:
- Type Assertions
- Object and array lookups
- Inaccurate type definitions
- The thing with variance and arrays
- Function calls don't invalidate refinements
- There Are Five Turtles
For each of these, I'll assess how common it is in practice, show what it looks like, and explain how you can avoid it.
Note that this post assumes you're using
--strict. If you're not, then there are more ways that TypeScript is unsound.
How often does this occur? It depends how disciplined you are about not using
any! But built-ins like
JSON.parse that return
any make it hard to avoid entirely.
If you "put an
any on it", then anything goes. The static types may or may not have anything to do with real runtime types:
The solution here is simple: limit your use of
any or, better, don't use it at all! Chapter 5 of Effective TypeScript is all about how to mitigate and avoid the static type disaster that is
any. The highlights are to limit the scope of
any and to use
unknown as a safer alternative when possible.
How often does this occur? Often (though not as often as object and array lookups).
The slightly less offensive cousin of
any is the "type assertion" (not the "cast", see my rant on this terminology):
as number in the last line is the type assertion, and it makes the error go away. It's the sudo make me a sandwich of the type system.
Type assertions often come up in the context of input validation. You might fetch JSON via an API and give it a type using an assertion:
Nothing ensures that this API is actually returning a
FunFact. You're simply asserting that it is. If it isn't, then the static type won't match reality.
What can you do about this? You can replace many assertions with conditionals (
if statements or ternary operators):
if block, the static type of
x1 is narrowed based on the condition, so the type assertion isn't needed.
For input validation, you can write a type guard function to do some run-time type checking:
Of course, you're still asserting that your type guard really guards the type. If you want to be more systematic about it, there are many possible approaches. One is to use a tool like Zod that's designed to solve this problem. Another is to generate JSON Schema from your TypeScript types (e.g. using typescript-json-schema) and validate the shape of your data at runtime using that. crosswalk takes this approach.
How often does this occur? All the time.
TypeScript doesn't do any sort of bounds checking on array lookups, and this can lead directly to unsoundness and runtime errors:
The same can happen when you reference a property on an object with an index type:
Why does TypeScript allow this sort of code? Because it's extremely common and because it's quite difficult to prove whether any particular index/array access is valid. If you'd like TypeScript to try, there's a
noUncheckedIndexedAccess option. If you turn it on, it finds the error in the first example but also flags perfectly valid code:
noUncheckedIndexedAccess is at least smart enough to understand some common array constructs:
If you're concerned about unsafe access to specific arrays or objects, you can explicitly add
| undefined to their value types:
The advantage of this approach over
noUncheckedIndexedAccess is that it lets you limit the scope (and presumably false positives) of that flag. The disadvantage is that it lacks the smarts of the flag: the
for-of loop will give you errors with this approach. It also introduces the possibility that you
undefined onto the array.
Finally, it's often possible to rework your code to avoid the need for these sorts of lookups. Say your API looks like this:
This API is very likely to lead to lookups in the
Instead, you might pass the
MenuItem itself to the callback:
This is safer from a static types perspective.
How often does this occur? Surprisingly rarely, but it's annoying and surprising when it does!
tsc and the library doesn't break any of the rules in this post!)
It's hard to show a specific example here since these kinds of bugs tend to get fixed once you highlight them, particularly for declarations on DefinitelyTyped. But here's one example in react-mapbox-gl that's been around for years. (Not to pick on react-mapbox-gl, we love you alex3165!)
How do you work around this? The best way is to fix the bug! For types on DefinitelyTyped (
@types), the turnaround time on this is usually a week or less. If this isn't an option, you can work around some issues via augmentation or, in the worst case, a type assertion.
It's also worth noting that some functions have types that are just very hard to model statically. Take a look at the parameter list for
String.prototype.replace for a head-scratching example. There are also some functions that are incorrectly typed for historical reasons, e.g.
How often does this occur? I've never personally run into this, but I also tend not to use very deep or complex type hierarchies.
What can you do about this? The best solution is to avoid mutating array parameters. You can enforce this via
This will prevent this type of unsoundness. Instead, you might write the example this way:
(See full playground example.)
Why does TypeScript allow this? Presumably because
readonly wasn't always part of the language. In the future you could imagine a "strict" option that would prevent these types of errors. In the initial example, the
addDogOrCat call should only be allowed with a subtype of
Animal if it's declared as
readonly Animal. This will have the side effect of pushing libraries to get better about declaring parameters
readonly, which would be a very good thing!
TypeScript used to have more issues around function calls and variance, and you might still see gripes about this online. But these were largely fixed with
--strictFunctionTypes, which was introduced with TypeScript 2.6 in November 2017.
How often does this come up? I've rarely seen it myself, though this may depend on your style and the libraries that you use.
Here's some code that doesn't look too suspicious at first glance (at least from a type safety perspective):
Depending on what
processor does, however, the call to
blink() might throw at runtime:
The issue is that
if (fact.author) refines the type of
string | undefined to
string. This is sound. However, the call to
processor(fact) should invalidate this refinement. The type of
fact.author should revert back to
string | undefined because TypeScript has no way of knowing what the callback will do to our refined fact.
How can you avoid this? A simple way is to avoid deeply mutating your parameters. You can enforce that callbacks do this by passing them a
Readonly version of the object:
Readonly is shallow; you'll need to use a tool like ts-essentials to get a
You can also avoid this issue by refining a value itself, rather than the object that contains it:
author is a primitive type (not an object), it cannot be changed by the callback and the
blink() call is safe.
How often does this come up? More or less never; if you run into it in real-world code, you might get mentioned at tsconf!
Anders explains this best:
I've never been able to find the issue he references in the talk. And I've heard rumors that there are now seven turtles. If you know more about either of these, please let me know in the comments!
Those are the seven sources! But maybe there are eight, or nine, or ten. If you have an example of unsoundness that doesn't fit into any of these categories, please let me know and I'll update the post.
- Ryan Cavanaugh offers an example that stems from how TypeScript handles function assignability and optional parameters.
- Ryan offers another example stemming from a loss of information when you assign to a type with optional properties.
- Are unsound type systems wrong?, a discussion of types of soundness and TypeScript's deliberate choice to be unsound (see also HN Comments).
- A Note on Soudness from the TypeScript handbook; this page gives background on some of TypeScript's design decisions.
- TypeScript Playground Soundness example; this is a built-in example on the TypeScript Playground. Note that it does not have