Zod,
I am a big fan of TypeScript, but one thing that I still find lacking compared to a real strongly type language: the runtime validation of the data.
While a static type checker esnures everything works, eventually we have to interact with the scary outside world.
This can include user input, API responses, streamed data, reading data from disk or even loading environment variables.
Lying interfaces
One thing that is more harmful than no interface, is a lying interface. This will set you —and your team— up for a world of unnecessary debug sessions.
Let's say you have a neat generic function that fetches data from an API:
You have properly declared an interface for the data you expect:
And when you call the function you expect to get the data you want:
At first glance the interface looks fine, the function has explicit type inputs and outputs.
But theres a catch — or rather 2 lies:
- The function can return anything, not just
ApiData
. - The function can throw an error, but you do not know that from the interface. You are left in the dark about what could go wrong.
You can easily spot those lies in source code whenever you see an as T
which should be read as as LIE
.
Removing the lies
We can enhance the interface by adding a runtime validation library like Zod1. This requires some initial extra typing, but it pays off in terms of new type-safe superpowers inside your IDE.
Instead of defining a type directly, we define a schema with Zod
and infer the type from it.
Now, when we call the function, we are sure that the data we get is according to the schema. And if not, we get proper typed errors.
Zod in the real world
For my client, Growy, we are dealing with unstructured data from various sources.
Growy is revolutionizing vertical farming by heavily relying on (hundreds of) IoT devices sending and receiving data to/from the cloud.
Our software is handling data from two primary sources: DynamoDB and MQTT messages.
In the software, there was efford put into data-classes that allow for creating data objects for static type-safety.
For DynamoDB queries, there were functions like:
And for MQTT messages, there were handlers that parsed data2 as follows:
But, you might have noticed some as LIE
s in the code.
When parsing a record from DynamoDB, we are assuming that the data is always there and of the correct type. Similarly, when parsing an MQTT message, we are assuming that the payload is always a stringified JSON object containing the correct data.
While all interfaces and static types are correct, runtime data could still be (and sometimes was) wrong.
The problem
As we tested our platform with simulated (IoT) devices, everything seemed to work smoothly. But when the simulated devices were being replaced by their actual real-world counterparts, that is when things got interesting.
When we started to get actual data from the real world, bugs started to surface. The data we were expecting and worked with in our simulated environment was not always the correct data. The interfaces from “the hardware team” and “the software team” were not always in sync.
Some bugs were found immediately, as the services started breaking down when the wrong data was received:
TypeError: Cannot read property 'foo' of undefined
or NaN
values after calculations were not uncommon.
But some bugs were more subtle. Wrongly received data was stored in the DynamoDB table and only processed in later stages, making it harder to identify the root cause.
Adding runtime validation
To prevent services from crashing and data from being stored incorrectly, we introduced runtime validation to our data objects.
When I joined the project, which was already quite large, it had adopted a heavy Object-Oriented Programming (OOP) style. The data objects were used exetensively throughout the codebase. As a result, we decided to not go full-in on the zod way.
Instead, we opted for a hybrid approach, adding zod parsing capabilities to the existing classes. This involved some TypeScript magic, but allowed us to leverage the benefits of runtime validation without disrupting the overall codebase structure.
The following code took some time to made, had its' fair share of issues while developing and also gave me some headaches.
The following Parsable
class serves as the foundation for our runtime validation framework:
Okay, busted. In our code we also use some as LIE
s, but let me break it down.
data as Input
Our constructor requires an Input
type and does not allow the unknown
data we pass to the parse
method.
as unknown as GeneratedParsable<Output, TypeDef, Input>
A double LIE
! To make your IDE happy we need to return any form of our GeneratedParsable
interface.
As the shape of the schema can be (almost) anything, typescript will infer everything from the genericsOutput
, TypeDef
and Input
and use them in theGeneratedParsable
interface.
Luckely, all of the above happens behind the scenes and we can extend the Parsable
in the data objects to give it runtime validation capabilities.
With this setup, we can now use the parse
and safeParse
methods to validate any unknown data.
On top of this, even new instances of any Parsable
class will be validated using the schema and giving type-hints in the IDE. Adding some extra static type checking magic to the mix.
From this moment onwards we can be sure the data we are working with is in the expected format, no more need for as LIE
s.
When receiving faulty data, we will be better equipped to pinpoint and handle it gracefully.
The aftermath
After implementing runtime validation, services started reporting a lot more faulty data inputs. Initially, the team was not very pleased with the constant need for fixing unnecessary errors. Instead of adding new features the focus was on data integrity for nearly a full month.
However, as time passed, received data became increasingly in line with the expected format. This led to better discussions between teams about data formats and interfaces, as faulty data would no longer be accepted.
As a result, the platform is now more stable, and the reliability of the data has improved significantly.
Edit
Before leaving the project, I was surprised with a gift from the team. They had named a robot after me, forever engraving zod
(and myself) in the Growy system.

Room for improvement
While the hybrid approach worked well for this project, it is not perfect. For instance, it does not work with more complex types like like unions and intersections.
When starting a new project, I would personally be going full-in on the zod way. This would remove the need for the Parsable
entirely.
You could then use the GrowthCycle
type when only type information is needed.
Or pass the GrowthCycle
zod object when data validation is required.