The Seven Sources of Unsoundness in TypeScript

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:

const x = Math.random();
// type is number

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:

const xs = [0, 1, 2];  // type is number[]
const x = xs[3]; // type is number

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:

  1. any
  2. Type Assertions
  3. Object and array lookups
  4. Inaccurate type definitions
  5. The thing with variance and arrays
  6. Function calls don't invalidate refinements
  7. 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:

function alertNumber(x: number) {
alert(x.toFixed(1)); // static type of x is number, runtime type is string
}
const num: any = 'forty two';
alertNumber(num);
// no error, throws at runtime:
// Cannot read property 'toFixed' of undefined

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):

function alertNumber(x: number) {
alert(x.toFixed(1));
}
const x1 = Math.random() || null; // type is number | null
alertNumber(x1);
// ~~ ... Type 'null' is not assignable to type 'number'.
alertNumber(x1 as number); // type checks, but might blow up at runtime

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:

const response = await fetch('/api/fun-fact');
const fact = await response.json() as FunFact;

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):

const x1 = Math.random() || null;  // type is number | null
if (x1 !== null) {
alertNumber(x1); // ok, x1's type is number
}

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:

function isFunFact(data: unknown): data is FunFact {
return data && typeof data === 'object' && 'fact' in data /* && ... */;
}

const response = await fetch('/api/fun-fact');
const fact = await response.json();
if (!isFunFact(fact)) {
throw new Error(`Either it wasn't a fact or it wasn't fun`);
}
// type of fact is now FunFact!

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:

const xs = [1, 2, 3];
const x = xs[3]; // static type is number but runtime type is undefined.
alert(x.toFixed(1));
// no error, throws at runtime:
// Cannot read property 'toFixed' of undefined

The same can happen when you reference a property on an object with an index type:

type IdToName = { [id: string]: string };
const ids: IdToName = {'007': 'James Bond'};
const agent = ids['008']; // static type is string but runtime type is undefined.

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:

const xs = [1, 2, 3];
const x3 = xs[3]; // static type is number | undefined
alert(x3.toFixed(1));
// ~~ Object is possibly 'undefined'.
const x2 = xs[2]; // static type is number | undefined
alert(x2.toFixed(1));
// ~~ Object is possibly 'undefined'.

noUncheckedIndexedAccess is at least smart enough to understand some common array constructs:

const xs = [1, 2, 3];
for (const x of xs) {
console.log(x.toFixed(1)); // ok
}
const squares = xs.map(x => x * x); // also ok

If you're concerned about unsafe access to specific arrays or objects, you can explicitly add | undefined to their value types:

const xs: (number | undefined)[] = [1, 2, 3];
const x3 = xs[3]; // static type is number | undefined
alert(x3.toFixed(1));
// ~~ Object is possibly 'undefined'.

type IdToName = { [id: string]: string | undefined };
const ids: IdToName = {'007': 'James Bond'};
const agent = ids['008']; // static type is string | undefined
alert(agent.toUpperCase());
// ~~~~~ Object is possibly 'undefined'.

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:

interface MenuItem {
id: string;
displayText: string;
icon?: string;
hoverText?: string;
// ...
}
interface MenuProps {
menuItems: {[id: string]: MenuItem};
onSelectItem: (id: string) => void;
}

This API is very likely to lead to lookups in the onSelectItem callback:

const menuItems: {[id: string]: MenuItem} = { /* ... */ };
Menu({
menuItems,
onSelectItem(id) {
const menuItem = menuItems[id]; // oh no! object lookup!
// ...
}
})

Instead, you might pass the MenuItem itself to the callback:

interface MenuProps {
menuItems: {[id: string]: MenuItem};
onSelectItem: (menuItem: MenuItem) => void;
}

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:

function addDogOrCat(arr: Animal[]) {
arr.push(Math.random() > 0.5 ? new Dog() : new Cat());
}

const z: Cat[] = [new Cat()];
addDogOrCat(z); // Sometimes puts a Dog in a Cat array, sad!

What can you do about this? The best solution is to avoid mutating array parameters. You can enforce this via readonly:

function addDogOrCat(arr: readonly Animal[]) {
arr.push(Math.random() > 0.5 ? new Dog() : new Cat());
// ~~~~ Property 'push' does not exist on type 'readonly Animal[]'.
}

This will prevent this type of unsoundness. Instead, you might write the example this way:

function dogOrCat(): Animal {
return Math.random() > 0.5 ? new Dog() : new Cat();
}

const z: Cat[] = [new Cat(), dogOrCat()];
// ~~~~~~~~~~ error, yay!
// Type 'Animal' is missing the following properties from type 'Cat': ...

(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):

interface FunFact {
fact: string;
author?: string;
}

function processFact(fact: FunFact, processor: (fact: FunFact) => void) {
if (fact.author) {
processor(fact);
document.body.innerHTML = fact.author.blink(); // ok
}
}

Depending on what processor does, however, the call to blink() might throw at runtime:

processFact(
{fact: 'Peanuts are not actually nuts', author: 'Botanists'},
f => delete f.author
);
// Type checks, but throws `Cannot read property 'blink' of undefined`.

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:

function processFact(fact: FunFact, processor: (fact: Readonly<FunFact>) => void) {
// ...
}
processFact(
{fact: `Peanuts aren't actually nuts`, author: 'Botanists'},
f => delete f.author
// ~~~~~~~~
// The operand of a 'delete' operator cannot be a read-only property.
);

(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:

function processFact(fact: FunFact, processor: (fact: FunFact) => void) {
const {author} = fact;
if (author) {
processor(fact);
document.body.innerHTML = author.blink(); // safe
}
}

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.

Further reading:

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
comments powered by Disqus
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »