TypeScript Isn’t type safe. But you can fix that.


There are of course a number of tweaks that can be made to TypeScript configuration files to enable basic safety at the language level. The problem is that once you’ve made those tweaks, you still have no guarantees that the data you’re plugging into your application – or sending upstream of it – will have the shape it’s expected to have.
Data that is not purpose fit can cause critical failures within your application, as well as other applications that depend on it.
There is no first-party solution to the problem of runtime type safety in TypeScript. Nor is there a single, canonical library recommended to solve it. Being a transpiled language, TypeScript is designed to get out of the way with a minimal runtime footprint, which means runtime type safety hasn’t been – and likely won’t be – implemented.
At Deliberate, we’re big advocates of safe and error-free applications, and have explored a number of the runtime type safety options available for TypeScript. And while many libraries and approaches exist – all with similar goals in mind – we’ve settled on Zod for its elegant combination of simplicity, brevity, legibility and flexibility.
TypeScript is mostly effective at preventing developer errors. However, issues occurring at the boundaries of your application – where data is consumed by your application, or sent outside your application to be used elsewhere – are invisible to TypeScript, as its safety features only operate at compile time.
Once your code’s compiled, it’s just plain JavaScript, and JavaScript has no inherent guarantees about the shape of the data being handled by it.
Additionally, TypeScript has a few blind spots when it comes to evented communication, which we’ll touch on below.
Consider, for example, this fetch request:
After .json() is called, can we safely assume that the data we’re consuming matches our use case? Can we rest assured that `settings.json` will export a valid `endpoint` value for us? Unfortunately, in both cases, we can’t.
An easy acronym we can use to remember the boundaries at which we’ll need runtime type safety is FIRE. That stands for Fetches, Imports, Repositories and Events. Let’s break those down:
Wherever data is brought into or sent away from your application. This covers JavaScript built-ins like fetch and XMLHttpRequest and third-party libraries like Axios.
Occasionally, you may need to import static JSON files into your application for use at runtime, or read files that contain structured information from a disk. It is always worth checking whether the data contained in these files is fit for purpose.
Databases – especially document stores – and other data storage systems fall under this category. It’s essential to let your application know what to expect from these repositories.
JavaScript is rife with evented features that use a messaging layer to communicate between processes. These include websockets, server-sent events, web workers and iframes. Messages aren’t implicitly validated, so it’s wise to ensure that they follow the correct format wherever they’re used.
There are a few cases in which implementing your own runtime types isn’t necessary. You may not want to do so for data that comes from any of the following (non-exhaustive) list of sources:
TypeScript provides type guards as a language feature, allowing developers to roll their own libraries for this use case. However, implementing them by hand is a laborious process. Many runtime type checking libraries abstract TypeScript’s underlying functionality, in addition to adding their own features. Why reinvent the wheel?
Every library-based solution has its share of pros and cons. Let’s run through a (non-exhaustive) handful of the approaches we’ve tried and the conclusions we’ve drawn after using them.
An old community favourite, JSON Schema is a reliable workhorse but mandates a few additional development steps.
A favourite among many functional programmers who integrate fp-ts – by the same author – into their codebases, io-ts is one of the more complex solutions in circulation.
Zod was designed with both correctness and developer experience in mind. It doesn’t reinvent the wheel or perform any magic, and it’s very easy to see what’s going on at a glance.
We’ve ultimately decided upon Zod as it’s feature-complete, easy to learn, has some handy additional functionality built into it, and doesn’t mandate the use of extraneous tooling, libraries, or patterns. While it’s not incredibly fast, it's fast enough to have a negligible performance impact in real-world scenarios.
Let’s expand upon the unsafe example we demonstrated earlier. We can make it perfectly safe for use by adding two Zod type specifications upfront, and validating our incoming data against them.
You can see from our updated example that this piece of functionality is unable to result in unexpected data. The types check out at compile time, and we can safely assume that this function will work wherever it’s needed.
Here is the full gist for comparative purposes.
You might wonder whether these changes need to be made all at once across the board, or whether you’ll be able to implement them piecemeal into an existing codebase.
The great news is that libraries like Zod are designed for incremental adoption, so it’s never too late to introduce runtime type safety into your application!