Using ZOD at the edge of our TypeScript type system

Using ZOD at the edge of our TypeScript type system
Photo by Markus Spiske / Unsplash

So I'm pretty sure we have all seen the horrific keyword called as being used all over a codebase. Now I'm not saying this is wrong 100% of the time. But it could be a key indicator that we've stepped outside of our type system already!

Sometimes the boundaries of the type system are very vague, or they might as well -  be completely hidden, because the facts are hard to find beyond layers of abstractions. In other cases, libraries might give us a false sense of security because - there in fact - is a type being returned. Maybe you're even working on a fancy codebase where some code and types are being generated based on an OpenAPI spec.

Where does the type system end?

The boundaries of the type system - like i said before - are sometimes quite difficult to grasp. Here is a question that I often ask myself to make it a bit easier: 'Did this data leave the codebase in any way shape or form?'. This might be a bit vague again, but here are some more specific questions we can ask ourselves; did the data go over a network?, Was it stored in the session storage?,  maybe in the local storage? or any other place where we don't have full control over it? Let's say we built a strictly typed way to store data in our localStorage. We might do something like this:

type Fruit = 'Orange' | 'Apple' | 'Banana'

const key = 'fruit'
const data: Fruit[] = ['Apple', 'Orange']

localStorage.setItem(key, JSON.stringify(data))

What we have done now, is that we have made our fruit - step outside of our type system. Since we have no control over what actually happens to the data, it is outside of our type system. A user might go into their developer tools and alter the data, maybe they are even so evil that they add a 'Tomato' to our fruit array!

Now getting the data back typically looks something like this:

const storage = localStorage.getItem(key)
const fruit = storage ? (JSON.parse(storage) as Fruit[]) : undefined

Do you notice the as keyword here? We need to do this because the localStorage.getItem function returns a string |null Which makes sense, since local storage only stores strings. "But we know what the data looks like, we put it there ourselves! so we can just type cast it!" - A naive person would say. Now this indeed makes our editor stop screaming at us, but at this point our data went outside of the type system and now there is no guarantee that the data still satisfies the type.

We have looked at a simple example with local storage, but this same principle applies to so much more scenarios, Maybe our codebase consists of multiple individually deployed layers, but since they live in the same monorepo, they're able to share the types between the two. This is a false sense of security though. If - when deployed - our code will send data from one place to the other, even though the sender, sends the data with the type being ABC and our receiver has that same type ABC, set for the incoming data. The data itself went outside of our type system, and should first be validated/parsed. Maybe we're using any kind of HTTP Library that allows us to give the request function a type as a generic, then the return type of that function will indeed be our type, but there is no guarantee that the data actually satisfies the type. It's a false sense of security!

Why is this important?

When we would use the as keyword, to type cast something as a type, what we are essentially doing is wrapping our raw data to look like something else from the outside. Let's say we are getting a Lemon, but we wrap it (type casting) it into an Orange. Everything seems to be fine, if we look at it one could think; yep, that's definitely an Orange. Even on closer inspection, still an Orange. Because to TypeScript, it IS an Orange. But let's say we take a bite, we would definitely have a different reaction than what we expected! When we use ZOD we are actually making sure that before we declare our raw data to be an Orange we check all the characteristics of it, making sure it - in fact - is an Orange!

so in conclusion; having types is not the same as having type safety!

Now what?

So what can we do...? Well simply said we need to have some kind of system to verify that our 'raw' data is actually satisfying the type. You can spend a lot of time writing your own validation function to check every little thing in detail, but why don't we use the power of open-source!

A quick intro into ZOD

This is where ZOD comes in! Quoted from their documentation, ZOD is a "TypeScript-first schema validation with static type inference". ZOD is an extremely powerful validation/parsing library with a lot of built in features. Instead of defining our types using Typescript, we can define Schemas using ZOD, and infer a Typescript type from them! Here's an example

const VeggieSchema = z.object({
  name: z.string(),
  properties: z.object({
    color: z.string(),
    juicy: z.boolean(),
  }),
})

This is a very simple schema, but as you can see we can also nest schemas further with other z.object({})'s. Now to get an actual Typescript type from this we can infer it.

type Veggie = z.infer<typeof VeggieSchema>

Here, inferring the VeggieSchema into a Typescript type called Veggie will result in this:

type Veggie = {
    name: string
    properties: {
        color: string
        juicy: boolean
    }
}

Using ZOD at the edge of our type system

Now that the ZOD basics are out of the way we can actually start to use it to protect our type system, we will do this at the 'edge', meaning the last possible place where something comes back into our type system. As I explained before, when we store data e.g. in the session storage, it's outside of our type system, if we want to get it back into our type system in a safe way, we'll have to validate the incoming data. We can do this using ZOD!

Let's use the first fruit example again. The first thing we want to do is change our Typescript type into a ZOD schema and infer the Typescript type from the schema.

const FruitSchema = z.enum(['Orange', 'Apple', 'Banana'])
type Fruit = z.infer<typeof FruitSchema>

The Fruit type - just as before - will result in a union type looking like this: 'Orange' | 'Apple' | 'Banana'.

So let's say we do the same thing for storing the data in the local storage, but this time we use the newly from-ZOD-inferred TypeScript type Fruit

const key = 'fruit'
const data: Fruit[] = ['Apple', 'Orange']

localStorage.setItem(key, JSON.stringify(data))

The data is now stored in the local storage, outside of our type system. Now to get it back into our type system in a safe way we will use the parse function from ZOD. This function validates the data and throws if it doesn't satisfy our schema. (You can also use safeParse if you do not want to throw but still want to get the result back). This is how we can do this:

const storage = localStorage.getItem(key)
const fruit: Fruit[] | undefined = storage ?  FruitSchema.parse(JSON.parse(storage)) : undefined

Note: We have to fall back here to undefined, because local storage isn't always set by default.

Doing this using ZOD instead of using the as keyword, we have actual security that the data we're putting back into our type system satisfies the type!

In conclusion

A lot of times it's quite difficult to see where our type system ends and begins again. The as keyword is often a good indicator, that if it's necessary to use it, that could tell us that we are creating a false sense of security for ourselves and that we're essentially breaking our type system.

We can use ZOD as a way of validating incoming data from outside of our type system and bring it back into our type system in a safe way!