Runtime Type Safety in TypeScript
TypeScript Isn’t type safe. But you can fix that.
Here’s something they don’t tell you from the get-go: TypeScript isn’t really type safe.
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.
When and Where to Use Runtime Types
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.
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:
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.
When Not to Use Runtime Types
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:
- Boundaries over which you have complete control – for example, an endpoint and a consumer within the same codebase, which can share the same TypeScript declaration(s).
- gRPC, in which case you’ll want to generate TypeScript types from protocol buffers.
- GraphQL, which has a utility for generating TypeScript types from schemas.
- OpenAPI, which, like the previous two items, features a way to generate TypeScript types based on specifications.
- Ultra-stable, versioned APIs or static sources of information that are battle tested and known to work exactly according to spec.
Why We Picked Zod
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.
- It’s a specification, rather than a library tied to a language.
- Being a specification, it can trivially be used for validation within any number of discrete languages and/or applications within your stack.
- It requires a build step in addition to the integration of a validator of your choice.
- Type inference is unavailable without code generation.
- There’s no canonical JSON Schema implementation for TypeScript.
- The schema itself is verbose and has a learning curve.
- Due to the flexibility of the ecosystem, there’s no directional enforcement, which means you’re able to generate JSON Schema from TypeScript, or vice versa. This can unfortunately lead to disparate sources of truth and multiple custom build systems within a codebase.
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.
- io-ts types are composable.
- Type refinement (now known as “branding” in io-ts) enables the creation of aliased types that incorporate validation over and above the library’s out-of-the-box offering.
- Its functional style appeals to FP enthusiasts.
- It has excellent type inference capabilities.
- It mandates the inclusion of fp-ts as a peer dependency.
- The idioms introduced by fp-ts pose a fairly high early learning curve for many TypeScript developers.
- Functional programming in TypeScript might not appeal to everyone.
- While its type error reporting is modifiable, its default reporting could use some refinement for the sake of legibility.
- Included types are somewhat barebones, and require either an additional library or hand-rolled branded types to be added for further refinements.
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.
- Zod types are both extendable and composable.
- Its API is user-friendly, idiomatic and easy to learn.
- There’s a built-in safe parser for handling errors gracefully.
- It has no external dependencies.
- It has a minimal footprint – both in terms of written code and library bundle size.
- Additional type refinements are included out of the box for common use cases (e.g. URL strings).
- There’s a small overhead inevitably introduced by a bundled dependency.
- There are faster solutions out there, which trade simplicity for speed.
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.
How We Make Our Code Type Safe
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.
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!