First Impressions on Frontend Runtime Safety - Zod in a Day
How to achieve runtime safety with Zod on the Frontend
Most static frontend apps rely on fetching data over the network to function. And since "all input is evil", we have to assert fetched data until we can trust them to work with our code. And that is why type safety in the runtime is important.
TypeScript is enough, right? Maybe not
In all the applications I've written, I've really only implemented compile-time safety with TypeScript. Yes, I have written parsers for APIs, but type safety is always secondary. The primary goal of that layer is always parsing. This means I can never truly trust that my API response will be the expected type during runtime because TypeScript is a transpiler and the actual code executes in JavaScript. (Sorry if this is your first time hearing about this, but yes. Just because you cast an object to a type in TypeScript, it doesn't mean anything in the runtime). This has always been a nagging issue in the back of my head, but today I seem to have found a solution that doesn't involve writing a bunch of type guards.
Why TypeScript is not enough
What do we want on our API layer in the application. We want a few things:
- we want compile-time definitions to make sure our code behaves and builds correctly
- we want to trust the API schema is correct, in the runtime
- we want to handle errors when the schema is wrong in the runtime
TypeScript already gives us compile-time definitions, which is great. It allows us to catch logical errors during static analysis stages and ensures the correctness of our logic. However, it does nothing in the runtime because, once again, it is a transpiler that never reaches the runtime.
All we need now is a parsing layer for the runtime. This layer allows us to catch schema errors in our API responses and handles them accordingly, in the runtime.
What is the exact gap with runtime safety?
Take a CRUD application. It has a Single-Page-App, SPA, static frontend and a RESTful endpoint. Both of them are in separate repositories and do not share any interfaces. As a front-end developer, you have to define interfaces and write fetching logic for the SPA. But how can you assure that your frontend always has the correct schema?
- you can use code generation tools to generate TypeScript interfaces that you import into your SPA, assign to the corresponding functions in your API layer
- or you write them manually in the API layer
Either way, you would not get runtime safety.
What you need, is a step in your parsing step that parses your data against a pre-defined schema. It would show errors in a safe way. This way, you only have to guard against your API once instead of writing question mark dots (optional chaining) and inline type-checks all over your codebase. And this is where Zod comes in.
Zod schema: define an interface that checks your data
What is a schema in Zod? It is just a representation of a piece of data. It defines the shape of a piece of data and how it should be validated. Let's look at it in code.
In this example, we are defining a Staff schema with two attributes, userId
and userName
before inferring a TypeScript type from it. We can use the TypeScript type for compile-time safety, but since the Staff
schema exists in the runtime, we can use it to assert runtime safety. Something like this.
Assuming getStaff
uses a fetch or Axios API internally, the promise will throw an error when you have networking issues (e.g. HTTP400). But it doesn't detect issues in the API response body. That is why runtime safety is important. By parsing our response through the StaffArray
schema, we can assert its shape and set error messages accordingly.
What did we achieve in the end?
By doing a parsing step with a Zod schema, we can infer a TypeScript interface for compile-time safety, but we also get runtime safety since it handles the parsing and provides errors when our shape is wrong. Rather than waiting until the erroneous data hits our business logic and then we handle it at that level, we now can implement it trivially in our API layer.
- Compile safety is preserved without writing a lot of additional code
- Runtime safety is achieved without writing complex parsing or guard functions
A codesandbox example
To illustrate what I've written, here is a code-sandbox repo with that exact implementation.