Parsing REST API Responses in Typescript using Zod
Matt Newhall8 min read
Parsing REST API Responses in Typescript using Zod
This is quick introduction guide to Zod aimed at newer developers, and also contains some advice for developers working on a project together whilst using Zod.
Debugging is a large part of development, so one of our primary goals as developers should be to make this process as seamless and self-explanatory as possible. When it comes to developing APIs, we want to make them robust to ensure that debugging them is hassle-free, and provide clear error messages to let developers know where problems can lie in their implementation. API schema validation can accomplish this by ensuring that we return sanitised, unpolluted data which will increase the stability of our codebase.
Let’s start by looking at a basic API, and the potential pitfalls they can cause:
A simple API request - what could go wrong?
Let’s assume we have an API with a single endpoint: user/{id}
. This fetches the details of a single user stored in a backend database, as can be seen in the diagram above. On our frontend, we will start out with a basic fetch
request. We are going to query user
and pass in a generic user id of 1234
.
// Define the types of data that the API should return
type UserType = {
firstName: string;
lastName: string;
dateOfBirth: Date;
favouriteBook: string;
};
// A GET request to our endpoint, converted to JSON
const userJSON: UserType = fetch(process.env.API_ADDRESS + "/user/1234", {
method: "GET",
}).then((response) => response.json());
Now that we have our response from the API, let’s take this data and find the user’s favourite author. These are stored in an enum in our code in this example:
// Define an enum mapping books to authors
const BOOKS_TO_AUTHOR = {
"Lord of the Rings": "J. R. R. Tolkien",
"Moby Dick": "Herman Melville",
} as const;
type BooksAuthorType = keyof typeof BOOKS_TO_AUTHOR;
// Outputs the author of the user's favourite book
console.log(BOOKS_TO_AUTHOR[userJSON.favouriteBook as BooksAuthorType]);
// If user's favouriteBook is 'Lord of the Rings', this should output:
// J. R. R. Tolkien
This request would appear to work. We are correctly converting our response to a JSON object and storing it in userJSON
, and our UserType
defines favouriteBook
as a required property so we should not need to check if the field first exists. So let’s run it!
Unhandled Runtime Error
TypeError: Conversion of type 'string[]' to type '"LORD OF THE RINGS"' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
15 | const userJSON: UserType = await fetch(process.env.API_ADDRESS + '/user', {
16 | method: 'POST',
17 | body: JSON.stringify({ user_id: 1234 }),
18 | }).then((response) => response.json());
19 |
> 20 | console.log(BOOKS_TO_AUTHOR[userJSON.favouriteBook as DetectionStageType); | ^
Why has this happened? While we have defined our UserType
, the type we have defined may not precisely match the database schema we are pulling from. The presence of string[]
in the runtime error would imply that the data may exist as an array in the source database - but we assumed that this was a string in our type! Let’s take a look at the database schema (in our case, it is defined in Prisma) to understand what really happens:
model User {
id String @id @default(uuid())
firstName String
lastName String
dateOfBirth Date
favouriteBook String[]
}
As expected, we’ve defined favouriteBook
as a String[]
rather than a plain String
. This mismatch has likely come about either from simple developer error, lack of communication between teams, or a handful of other reasons. In this particular case, there was a clear communication error when creating variable names. If the developer had simply named this favouriteBooks
, even if they had typed it incorrectly, it would be clear to other readers that this should accept multiple books, hence an array would be the most logical choice of data type.
Without ensuring API responses are correct, several issues are raised:
- Imported data can be polluted, and prone to runtime errors.
- Debugging, especially with complicated API queries, can be difficult to do without detailed, explicit error messages.
- Code is less legible to other developers, as they do not have any idea of the structure of the data being returned from the API call.
This mismatch in typing can happen when trying to fetch data, so how can we validate that the data we receive matches what we expect?
Zod - a Schema Declaration & Validation Library
To install Zod, using npm
or the favourite package manager of your choice, simply run the following:
// for projects using npm:
npm install zod
// for other package managers:
// https://www.npmjs.com/package/zod#installation
Zod is a schema validation library that allows us to check that the data passed back complies with the rules that we expect data to follow. There are many benefits for this:
- More accurate error messages reduces lost developer time to finding errors
- Greater application stability from consistent typing in frontend and backend
For simple use cases, such as this project, Zod is preferable due to its fast relative performance time compared to other schema parsing libraries, and has a very easy syntax that beginners new to this field can understand. Zod isn’t without its faults though: if you’re looking for anything customisable, it may be worth taking a look at Joi. In addition, Zod is a much more widely adopted library, so most issues that crop up are extremely well documented and can be understood by a lot of other developers.
First, we can create a basic schema in zod
that mirrors our interface from before:
import { z } from "zod";
const UserSchema = z.object({
firstName: z.string(),
lastName: z.string(),
dateOfBirth: z.date(),
favouriteBook: z.string(),
});
With our schema defined, we can now try to validate our JSON response, and if we find an error we can visualise it more clearly:
try {
UserSchema.parse(response);
} catch (err) {
if (err instanceof z.ZodError) {
console.log(err.issues);
}
}
Finally, we can see what error we would receive when using Zod to validate our database query:
[
{
code: "invalid_type",
expected: "array",
received: "string",
path: ["favouriteBook"],
message: "Invalid input: expected array, received string",
},
];
Now the error is a lot clearer to the developer - we can see that when querying favouriteBook
, we expected to receive an array of strings, but we ended up only receiving a single string. Now we have the choice of either amending our Prisma schema to receive a string instead, or we can alter our UserSchema
in the frontend to accept multiple books in an array.
However, this has led to some waste in our code! Whilst we have defined our UserSchema
successfully, this is effectively the same format of data we have in UserType
. Unfortunately, Zod cannot use UserType
in place of our schema definition to validate API responses, as it requires its own custom typings in the type definition (e.g. z.string()
instead of string
, z.date()
instead of Date
, etc). But we can go in reverse!
const UserSchema = z.object({
firstName: z.string(),
lastName: z.string(),
dateOfBirth: z.date(),
favouriteBook: z.string(),
});
const UserType = z.infer<typeof UserSchema>;
Now we have validated API data, and waste has been reduced in our codebase by inferring the type of our data from the Zod type. Both of these elements make our code much more legible to other developers and increases the stability of this code!
Why use .parse()
, and not validate?
Parse, don’t validate is a concept that refers to how best to deal with checking if inputs types are correct or not (great article; check this out as well). The concept boils down to the underlying assumption in validation that a type can have valid and invalid instances. Let’s take an example of validating a type, T:
validateType: (T) => boolean;
In this case, validateType
takes a type T
and then by evaluating it returns a boolean
stating if the incoming object was a valid object of type T
or not. However, this means that invalid instances of T
can exist. Alternatively, using parsing:
parseType: (unknown) => T | Error;
We can take some data of an unknown type, and check if this matches what is expected of type T
. From this, we can output a guaranteed type T
, or handle the error in the event that it is not. This makes the schema checking system more Typescript-esque, making it more akin to a type guard than blindly assuming a variable’s type.
So what have we learned?
Zod is a fantastic library to use for better stability of apps and developer experience. With minimal setup, this can lead to much more painless debugging, especially on projects with multiple developers simultaneously making changes to a shared database or API. In this case, lapses in communication can lead to seemingly random crashes without the help of Zod or another validation tool.
To take this to the next step, Zod can be used in a middleware by passing in a schema and parsing it as normal. The main benefit of this that we can freely pass errors between our backend and our frontend and allows for greater separation of concerns: fetch data in the backend, parse data in the middleware, display/use data in the frontend. For a great follow-up blog article that goes in-depth on this further, check out this article.
Additionally, another step to take this schema parsing is to use tRPC, a super lightweight library that warns you of type errors and more between your backend and frontend without even having to run them. For monorepo projects, this is a great tool to eliminate parsing responses.