It would be quite dangerous to drive a car with blind spots, but not know exactly where those blind spots are. You still need to be extra careful, even with a full awareness of the blind spots. But at least you know where to put that extra care.
I look at using TypeScript in much the same way. Knowing its blind spots helps me know when to trust it and when to quadruple check. If you can drive a car without blind spots, then by all means go for the safest option. But if you are in a car with blind spots, learn its blind spots to reduce the risk. As Elm developers, we stay in Elm as much as we can. But we do need to reach out to JS/TS to leverage built-in web APIs, use the NPM ecosystem, etc. So for those times we need it, it's best to understand the blind spots.
In this post, I'll catalog the areas where TypeScript allows incorrect types to pass through.
any
typeThe biggest culprit for type blind spots in TypeScript is the any
type.
The any
type is similar to Elm's Debug.todo ""
, which has type a
(a type variable that could refer to anything). But there are some key differences:
Debug.todo
statements can't be used in production optimized code (elm make --optimize
)Debug.todo
can't be published in Elm packagesDebug.todo
, your code stops there!This is a huge distinction. With Elm, even if you manage to "fool" the type system with a Debug.todo
(or a hack like infinitely recursing to get any type), your code will not proceed with faulty assumptions. It's impossible to write Elm code that will continue on with incorrect types.
As an Elm developer, using TypeScript can feel like shaky ground because of this escape hatch. It's good to understand common sources of any
types to know where to look out for this kind of blind spot.
any
By default, TypeScript will use an any
to describe types that it can't infer, like function parameters without annotations.
function inspect(argument) {
argument("Careful! `argument` is implicitly `any`");
}
Using the noImplicitAny
option in your tsconfig.json
prevents this problem by giving an error in these situations and requires you to add an annotation. I recommend using the strict
option in your tsconfig.json
, which includes the noImplicitAny
option.
any
Even with the noImplicitAny
option, TypeScript will allow you to use explicit any
types. One useful technique here is to use the unknown
type as a substitute for any
. You can think of unknown
as a type-safe alternative to any
. unknown
doesn't create a blind spot in the type system that allows anything to flow through. Rather, it is much like a Json.Decode.Value
in Elm. You can't use it until you check its types.
function prettyPrint(value: unknown): string | null {
if (value === null) {
return "<null>";
} else if (value === undefined) {
return "<undefined>";
} else if (typeof value === "string") {
return value;
} else if (value && typeof value === "object") {
return "Object with keys: " + Object.keys(value).join(", ");
} else {
return null;
}
}
You can pass any value in to prettyPrint
, just like you would be able to if the argument was annotated as any
. But you must check the value before using it because it is unknown
.
You can use @typescript-eslint/no-explicit-any
to prevent explicit any
types in your own code. You can even configure it to auto-fix any
types in your code and change them to unknown
.
any
in core JavaScript APIsSome of the core JavaScript APIs have built-in any
types. Json.parse
is a common source of any
's leaking into your code:
JSON.parse(text: string): any
const definitelyNotAFunction = JSON.parse(
`"Parsing JSON can never return a function, right?"`
);
definitelyNotAFunction();
// TypeError: definitelyNotAFunction is not a function
Yikes! Not only can you assume the parsed JSON is any valid JSON value, but you can even treat it as something besides JSON! Even worse, this can escape into the depths of your code and result in type errors that are far away from their point of origin. In a nutshell, any
makes it hard to trust any of your code.
You can improve the safety of JSON parsing with tools that are similar to elm/json
decoding.
There are many different approaches to safer JSON parsing in TypeScript, and it's worth looking at different options and seeing if one is a good fit for your use case and has an API that suites your tastes.
Be on the lookout for any
types in core JS APIs. Any time you're dealing with dynamic runtime data you're likely to see this. The eval
function is a classic case of a function that truly does have any
type. That's a reminder of why any
is baked into TypeScript: JavaScript is inherently dynamic and dynamically typed.
any
types coming from NPM packages or type definitionsAlso be aware that TypeScript's declaration functionality allows you to declare the types of existing JavaScript code. These type declarations (usually bundled with an NPM package, or installed with npm install --save-dev @types/<package-name>
) often use any
types. And even without using any
, they could be incorrect because they are declaring types, not proving them soundly through typed code, as we are forced to do in Elm code and Elm packages.
Another place to be extra careful is the wiring of imports. When you do an import
in Elm, you know that it has resolved if your code compiles. With TypeScript, you can describe how to resolve modules using the paths
configuration in your tsconfig.json
. This can be convenient or necessary at times, but it's another instance where the TypeScript compiler trusts you to do things correctly and guarantee correctness.
The same is true for defining the lib
option to describe the types/runtime that you have available (for example, dom
APIs, es2020
, node
). TypeScript takes your word for setting this up correctly, but this could lead to incorrect type checking if you have lib
s declared that won't be there in your runtime.
The type systems in Elm and TypeScript are operating in fundamentally different ways, built to serve their intended use cases.
Elm is a sandbox where nothing can enter with assumptions about its types. You have to prove everything to the Elm type system.
TypeScript is built in a way that's meant to be good at gradual typing, allowing you to add type information to existing JavaScript code in an incremental way.
Any TypeScript code can throw an exception without the types indicating they might fail.
There's no way to tell by the types, which feels very different than Elm's errors as data. In particular, be on the lookout for functions around file IO and network requests.
If two references point to the same object reference, you can mutate the references according to their respective types, but they both modify the shared reference. This can lead to incorrect runtime types.
let numbers = [1, 2, 3];
let mixedValues: (number | string)[] = numbers;
mixedValues.push("Uh oh, this is not a number");
numbers.map((number) => number * 2);
// [2, 4, 6, NaN]
TypeScript assumes that the index you access is there.
const thisWillBeNull = [1, 2, 3][100];
thisWillBeNull * 2; // NaN
The type of thisWillBeNull
is number
(it is not nullable).
By default, TypeScript doesn't require you to exhaustively handle all cases in switch
statements.
This can be improved with typescript-eslint
switch-exhaustiveness-check
.
TypeScript's non-null assertion syntax (!
) is another place where TypeScript trusts you rather than proving correctness. You can disallow this in your own code with @typescript-eslint/no-non-null-assertion
Casts, also known as TypeAssertions, are another place where the TypeScript compiler will trust you and not check types. See Assertion Considered Harmul
There are several places where JavaScript will coerce types. This is often used as a feature, but it is bug-prone and warrants extra care.
Gary Bernhardt's famous Wat video shows some amusing examples of unexpected type coercion. The type system doesn't stop most of these, so they're worth extra attention.
Any chipping away at confidence means that you have to have a skeptical eye at every turn.
Let's think of it in reverse, though. Unit tests don't give us 100% certainty that our code is correct. Even Elm types don't give us 100% certainty that our types our correct. There are plenty of cases where we don't express every single constraint in the type system, and there's a point where it becomes so verbose to constrain your types that its more pragmatic to stop short of perfect types. Jeroen and I discuss this in our Make Impossible States Impossible Elm Radio episode.
Yes, TypeScript's type system is far less robust than Elm's. But it's far more robust than the type-safety you get in vanilla JavaScript! And I'll take all the added safety I can get! So any time I find myself reaching for JavaScript, I try to take advantage of TypeScript's added safety.
As much as I like to strive for soundness, correctness, and certainty, I will take any tool that can boost my confidence. None of these tools guarantee correctness at every level. But if you know what guarantees they offer, you can understand what suite of tools to use to give you as much confidence as possible.
Sign up to get my latest Elm posts and course notifications in your inbox.
Pure Elm content. Unsubscribe any time.
Configure TypeScript to Be Safer
In [TypeScript's Blind Spots], I attempt to catalog the ways that ways you can introduce unsoundness into TypeScript's type system, such as the any type. This...
Opaque Types Let You Think Locally
Elm's Opaque Types are a powerful tool for narrowing the surface area where you check a constraint. TypeScript's Branded Types give similar functionality but...
TypeScript Types Can Do Some Cool Things That Elm Can't
Yes, it's important to be aware of [TypeScript's Blind Spots]. Elm has a sound type system, whereas TypeScript does not have a sound type system (by design and...
TypeScript