Today I Learned: Validating Data in Zod
Validating input. You've got to do it, otherwise, you're going to be processing garbage, and that never goes well, right?
Whether it's through the front-end (via a form) or through the back-end (via an API call), it's important to make sure that the data we're processing is valid.
Coming from a C# background, I was used to ASP.NET Web Api's ability to create a class and then use the FromBody attribute
for the appropriate route to ensure the data is good. By using this approach, ASP.NET will reject requests automatically that don't fit the data contract.
However, picking up JavaScript and TypeScript, that's not the case. At first, this surprised me because I figured that this would automatically happen when using libraries like Express or Nest.js. Thinking more about it, though, it shouldn't have surprised me. ASP.NET can catch those issues because it's a statically typed/ran language. JavaScript isn't and since TypeScript types are removed during the compilation phase, neither is statically typed at runtime.
When writing validations, I find zod to be a delightful library to leverage. There are a ton of useful built-in options, you can create your own validators (which you can then compose!) and you can infer models based off of your validations.
Building the Amazin' Bookstore
To demonstrate some of the cool things that you can do with Zod, let's pretend that we're building out a new POST endpoint for creating a new book. After talking to the business, we determine that the payload for a new book should look like this:
What's in a Name?
Since the Book
type needs a valid Author
, let's build that out first:
Since Author
will need to be an object, we'll use z.object
to signify that. Right off the bat, this prevents a string, number, or other primitive types from being accepted.
This is a great start, but we know that Author has some required properties (like a first name), so let's implement that by using z.string()
With this change, let's take a look at our schema validation
However, there's one problem with our validation. We would allow an empty firstName
To make our validation stronger, we can update our firstName
property to have a minimum length of 1 like so.
Finally, we have a way to enforce that an author has a non-empty firstName!. Looking at the requirements, it seems like lastName
is going to be similar, so let's update our AuthorSchema
to include lastName.
Hmmm, it looks like we have the same concept in multiple places, the idea of a non empty string. Let's refactor that to its own schema.
Nice! We're almost done with Author
, we need to implement middleName
. Unlike the other properties, an author may not have a middle name. In this case, we're going to leverage the optional
function from zod to signify that as so.
With the implementation of AuthorSchema
, we can start working on the BookSchema
.
Judging a Book By It's Cover
Since we have AuthorSchema
, we can use that as our start as so:
We know that a book must have a non-empty title, so let's add that to our definition. Since it's a string that must have at least one character, we can reuse the NonEmptyStringSchema
definition from before.
Putting a Price on Knowledge
With title in place, let's leave the string theory alone for a bit and look at numbers. In order for the bookstore to function, we've got sell books for some price. Let's use z.number()
and add a price
property.
This works, however, z.number()
will accept any number, which includes numbers like 0
and -5
. While those values would be great for the customer, we can't run our business that way. So let's update our price to only include positive numbers, which can be accomplished by leveraging the positive
function.
With price done, let's look at validating the genre.
Would You Say It's a Mystery or History?
Up to this point, all of our properties have been straightforward (simple strings and numbers). However, with genre
, things get more complicated because it can only be one of a particular set of values. Thankfully, we can define a GenreSchema
by using z.enum()
like so.
With this definition, a valid genre can only be fantasy, history, or mystery. Let's update our book definition to use this new schema.
Now, someone can't POST a book with a genre of "platypus" (though I'd enjoy reading such a book).
ID Please
Last, let's take a look at implementing the isbn
property. This is interesting because ISBNs can be in one of two shapes: ISBN-10 (for books pre-2007) and ISBN-13 (all other books).
To make this problem easier, let's focus on the ISBN-10 format for now. A valid value will be in the form of #-###-#####-#
(where # is a number). Now, you can take this a whole lot further, but we'll keep on the format.
Now, even though zod
has built-in validators for emails, ips, and urls, there's not a built-in one for ISBNs. In these cases, we can use .refine
to add our logic. But this is a good use case for a basic regular expression. Using regex101 as a guide, we end up with the following expression and schema for the ISBN.
Building onto that, an ISBN-13 is in a similar format, but has the form of ###-#-##-######-#
. By tweaking our regex, we end up with the following:
When modeling types in TypeScript, I'd like to be able to do something like the following as this makes it clear that an ISBN can in one of these two shapes.
While we can't use the |
operator, we can use the .or
function from zod to have the following
With the IsbnSchema
in place, let's add it to BookSchema
Getting Models for Free
Lastly, one of the cooler functions that zod supports is infer
where if you pass it a schema, it can build out a type for you to use in your application.
Full Solution
Here's what the full solution looks like
With these schemas and models defined, we can leverage the safeParse
function to see if our input is valid.