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 number
for 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.
Many programming languages include proofs of soundness, or at least purport to be sound. Fun fact: in 2016, two researchers discovered that Java had become unsound! As we saw above, TypeScript is emphatically not sound. In fact, soundness is not a design goal of TypeScript at all. Instead, TypeScript favors convenience and the ability to work with existing JavaScript libraries.
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:
any
- 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.
any
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.
Type Assertions
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):
|
The 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):
|
Within the 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.
Object and array lookups
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 push
an 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 onSelectItem
callback:
|
Instead, you might pass the MenuItem
itself to the callback:
|
This is safer from a static types perspective.
Inaccurate type definitions
How often does this occur? Surprisingly rarely, but it's annoying and surprising when it does!
The type declarations for a JavaScript library are like a giant type assertion: they claim to statically model the runtime behavior of the library but there's nothing that guarantees this. (Unless, that is, the library is written in TypeScript, the declarations are generated by 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. Object.assign
.
The thing with variance and arrays
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.
This is a famous one. TypeScript TL Ryan Cavanaugh offers this example:
|
What can you do about this? The best solution is to avoid mutating array parameters. You can enforce this via readonly
:
|
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.
Function calls don't invalidate refinements
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 fact.author
from 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.
Why does TypeScript allow this? Because most functions don't mutate their parameters, and this sort of pattern is common in JavaScript.
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:
|
(Note that Readonly
is shallow; you'll need to use a tool like ts-essentials to get a DeepReadonly
.)
You can also avoid this issue by refining a value itself, rather than the object that contains it:
|
Because author
is a primitive type (not an object), it cannot be changed by the callback and the blink()
call is safe.
There Are Five Turtles
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.
Updates:
- 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.
- Oliver Ash offers an example stemming from how TypeScript models object spread as a deep intersection between generics whereas it really operates as a shallow intersection. See design notes on this issue.
Further reading:
- 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
strictFunctionTypes
enabled.