Validating Incoming Data Using Zod and TypeScript
When handling any sort if incoming data it is important to validate that the data is what it is expected to be. There are 2 types of validation:
- Validating the structure (i.e. the "shape" of the data)
- Validating the contents of the data
For the purposes of this article we will be talking about the first type of validation. When using TypeScript we may be lulled into a false sense of security by the presence of a type system, however it is important to remember that the type-system is a virtual, "pinky-promise" one. This is especially important to remember in the case of data that arrives in from some external location, for example unmarshalled JSON data.
Consider this simplistic example:
import axios from 'axios';
type SomeApiResponse = {
a: {
b: string;
};
};
const response = await axios.get<SomeApiResponse>(
'http://example.com/some-api',
);
console.log(response.data.a.b);
This is perfectly valid TypeScript and yet it will cause our program to crash if, for some reason, the API returns an object of a different shape (e.g. {"b":{"c":"d"}}
). Before you dismiss this as paranoia, I have seen this sort of code in production codebases and I have seen external APIs change their API contract with no warning!
The solution to this problem is to always validate the incoming data to be sure that it is of the shape that our code expects to see. Zod is a popular library that provides functionality to perform data validation and is a good first choice for a validation library. Here is a basic example of using it to validate HTTP response data:
import { z } from 'zod';
const someApiResponseSchema = z.object({
a: z.object({
b: z.string(),
}),
});
const response = await fetch('http://example.com/some-api');
const data = someApiResponseSchema.parse(await response.json());
console.log(data.a.b);
This code will throw an error if the data does not match the schema definition and therefore we can safely assume the shape of the data in subsequent code.
In practice, we will certainly want to handle any errors arising from our request. These include any validation errors, but also errors with making the request itself. So our code could look something like this:
async function getSomeData() {
const response = await fetch('http://example.com/some-api');
const data = await response.json();
return someApiResponseSchema.parse(data);
}
try {
const data = await getSomeData();
console.log('have data', data);
} catch (error) {
console.log('something went wrong', error);
}
This is a good start - the code works and is type-safe - but there is room for improvement. Right now there is no easy way to distinguish between the errors the function can throw, nor is there a way to capture the problem data for troubleshooting in the case of a validation error. We can improve this code by introducing a custom error type to capture extra troubleshooting data:
class ValidationError extends Error {
constructor(
public cause: unknown,
public rawData: unknown,
) {
super('Validation Error');
}
}
async function getSomeData() {
const response = await fetch('http://example.com/some-api');
const data = await response.json();
const parsed = someApiResponseSchema.safeParse(data);
if (!parsed.success) {
throw new ValidationError(parsed.error, data);
}
return parsed.data;
}
try {
const data = await getSomeData();
console.log('have data', data);
} catch (error) {
if (error instanceof ValidationError) {
console.log('validation failed', error.cause, error.rawData);
} else {
console.log('something went wrong', error);
}
}
This custom error pattern is very useful where errors may need to bubble up several layers of the call stack, or if your error logging happens at the middleware level that is far removed from the actual business logic.
All that is left is to make the validation a bit more generic, so that it can be reused by other data fetching functions:
import { type ZodTypeAny, type infer as zodInfer } from 'zod';
function validate<T extends ZodTypeAny>(data: unknown, schema: T): zodInfer<T> {
const parsed = schema.safeParse(data);
if (!parsed.success) {
throw new ValidationError(parsed.error, data);
}
return parsed.data;
}
async function getSomeData() {
const response = await fetch('http://example.com/some-api');
const data = await response.json();
return validate(data, someApiResponseSchema);
}
Extracting the logic into a separate function makes it easy to extend the functionality. For example, you could choose to include HTTP method and URL in the error metadata for troubleshooting.
Well done for reading this far - I hope you found it useful. :)