Zod makes Typescript even better

Zod makes typescript even better

Leonardo Ramos
ProFUSION Engineering

--

Have you ever tried to access a property from an undefined variable?

Oh yes, many of us have! And for many reasons. Today we will dive into one of those reasons.

Perhaps you have imported or fetched JSON data and want to access its attributes. Perhaps you want to make sure your .env variables are well set, or you received an untyped object from a colleague's code, or from the lib/sdk you are using.

In all these cases you've ended up receiving an untyped object, which ultimately will be handled as any, unknown or a string that should be a literal. And, for that, Zod can be your solution!

Defining Schemas and Types

In order to solve the validation problem we've been discussing, we need to create a schema. Let's play with a simple one:

import { z } from "zod";

const mySimpleSchema = z.string(); // schema defines what is the final type
const weirdObject: unknown = "I am an untyped object"; // "mocking" an unknown variable
const coolObject = mySimpleSchema.parse(weirdObject); // parsing object results in typed variable

console.log(typeof coolObject); // string
type InferredFromSchema = z.infer<typeof mySimpleSchema>; // will infer to the TS type that results from the schema, pretty cool!

So what happened here? We had a weirdObject that came in untyped, as unknown and then after parsing it, we ensured it followed our schema structure, returning then a coolObject, which is correctly typed. .parse() will throw an error in case the object does not follow the schema, if you don't like throw mechanism, you can take a look at safeParse.

That's the gist!

Deeper into the woods

Let's take a look at a more life-like example here. Imagine I need to fetch a JSON data from an API, and the return has some constraints:

  1. returns an object
  2. there is a key which is optionally added
  3. there is a string that needs to be formatted as an url
  4. there is a number that needs to be greater than or equal to 0
  5. there is a string which is always one of two values: literalA or literalB

Here's how we can validate our return with Zod:

import { z } from "zod";

const randBool = (): boolean => {
return Math.random() > 0.5
}

const mySillyFetch = (url: string): unknown => {
const r = {
url,
aLiteral: randBool() ? 'literalA' : 'literalB',
someKey: 'someValue',
anotherKey: {
inner: 5,
},
}
if (randBool()) {
// @ts-expect-error intentional!
r.anotherKey.random = 10
}
return r
}

const dataSchema = z.object({
someKey: z.string(),
aLiteral: z.enum(['literalA', 'literalB']),
anotherKey: z.object({
inner: z.number().nonnegative(),
random: z.number().optional(),
}),
url: z.string().url(),
})

const data = mySillyFetch('http://example.com')

const typedData = dataSchema.parse(data) // Done!

console.log(typedData)

In this example, mySillyFetch is mocking a fetch that doesn't know exactly how the object is structured. So let's take a look at how we solved those constraints:

  1. simply using z.object() solved our problem, just as there are tons of simple and complex types in their readme docs;
  2. simply chaining .optional() solved our problem (take a look at nullables, NaNs);
  3. simply chaining .url() we can ensure the string is well formatted (take a look at strings);
  4. simply using .nonnegative() solved our problem (take a look at number for number validations);
  5. zod has a .enum(...) (which is zod-native) that deals well with literals;

These are 5 simple examples, but I can assure you that, for any type, Zod has you covered! Maps, sets, promises, functions and even a custom one!

Refine, Coercion and Transform

We can find a lot of validation functions, but what if we want to create our own? Well, Zod has you covered too!

Refine

If you want to ensure a more specific rule, then you can refine:

const myString = z.string().refine((val) => val.length <= 255, {
message: "String can't be more than 255 characters",
});

This will ensure that the string actually has less than 255 characters, with a special error message.

Coerce

If we want to enforce a type, there are two options: coerce and transform. In the first case, we convert primitives like a runtime cast (e.g. Boolean('a')), with transform we can do whatever we want!

Let's take a look at how we can coerce primitives:

const schema = z.coerce.string();
schema.parse("tuna"); // => "tuna"
schema.parse(12); // => "12"
schema.parse(true); // => "true"

And as there are coercions for string, we have also for number, boolean (this one can be tricky), bigint and date!

Transform

A more powerful coercion is a transform:

const emailToDomain = z
.string()
.email()
.transform((val) => val.split("@")[1]);

This schema will always receive the domain after parsing!

Tricky example

I mentioned above booleans are tricky, but why is that so? Well, when coercing it will check if it's truthy or falsy. Let's see:

// trivial coersion
const aBooleanFromStringSchema = z.coerce.boolean();
console.log(aBooleanFromStringSchema.parse('false')); // true, is that what we really want?

// now evaluating the string values
const aBooleanFromStringSchema = z
.string()
.refine((val) => {
return val.toLowerCase() === 'true' || val.toLowerCase() === 'false';
})
.transform((val) => {
return val.toLowerCase() === 'true';
});

console.log(aBooleanFromStringSchema.parse('false')); // false! now it's the correct behavior

Okay, it makes sense. false is actually truthy, oh javascript...

Validating your envs!

To wrap up, we will pass through a code that might be on your repo soon enough: validating envs.

// create a file env.ts
import { z } from 'zod';

const envSchema = z.object({
HOST: z.string().default('localhost'),
PORT: z.coerce.number().default(6060),
SOME_BOOL: z
.string()
.default('false')
.refine((val) => {
return val.toLowerCase() === 'true' || val.toLowerCase() === 'false';
})
.transform((val) => {
return val.toLowerCase() === 'true';
}),
});

const env = envSchema.parse(process.env);

export default env;

Done! Your envs are parsed!

Advantages over competition

Zod is a very developer-friendly lib that concisely validates your objects, be it by typing them or verifying their values at runtime. As the zod documentation point out, there are some smaller advantages too:

  • Zero dependencies
  • Works in Node.js (even older ones!) and all modern browsers
  • Tiny: 8kb minified + zipped
  • Immutable: methods (e.g. .optional()) return a new instance
  • Concise, chainable interface
  • Functional approach: parse, don't validate
  • Works with plain JavaScript too! You don't need to use TypeScript.

Conclusion

Only people that had nightmares checking types and values in typescript/javascript can be as excited as I am with this lib. But I know many other people in the community may share this excitement too, since Zod has a lot of sponsors and a large community that uses their tools (see ecosystem and a growing number of downloads).

I hope you find this information useful and add Zod to your type-checking too! Have a nice coding day!

--

--