Zod, Expectations, and a Little White Lie
January 26, 2025 by Missie Dawes
You know how psychologists and therapists say communicating expectations plays a key role in maintaining healthy relationships? Turns out the same is true of code. Just like in relationships, assuming everything will “just work out” without clear communication is a recipe for disaster. Code, much like people, needs boundaries.
I recently implemented runtime validation in my Next.js app using Zod, a powerful TypeScript-first schema validation library. Up until that point, my Next.js app was using GraphQL to query data from my WordPress backend and naively assuming the response data would have the shape my TypeScript interfaces expected. Spoiler alert: the data and my interfaces were not always on speaking terms, and the silent treatment wasn’t helping anyone.
Implementing Zod validation allowed me to define strict schemas for the data my app expects to receive, particularly from external sources like my WordPress GraphQL API. By validating the data at runtime, I can ensure it conforms to the expected structure and types, reducing the risk of errors caused by unexpected or malformed data. Integrating Zod also provides an additional layer of confidence in my app’s robustness, bridging the gap between compile-time type checking in TypeScript land and real-world data handling in Zod land. Zod doesn’t just set expectations—it enforces them, which is a level of accountability that even some humans could benefit from.
For the most part, adding Zod to the project was simple. I already had TypeScript interfaces defined for each of the data structures I was querying through API calls. For example:
export interface ExpandedPostQueryObject {
post: ExpandedPost | null;
}
export interface ExpandedPost {
databaseId: number;
title: string;
dateGmt: string;
slug: string;
author: SimplifiedAuthorNode;
featuredImage?: SimplifiedFeaturedImageNode | null;
content: string;
}
I wrote Zod validators, or schemas, for each of the interfaces along with a function to parse input into the given schema:
export const ExpandedPostValidator = z.object({
databaseId: z.number(),
title: z.string(),
dateGmt: z.string(),
slug: z.string(),
author: SimplifiedAuthorNodeValidator,
featuredImage: SimplifiedFeaturedImageNodeValidator.optional().nullable(),
content: z.string(),
});
export const ExpandedPostQueryObjectValidator = z.object({
post: ExpandedPostValidator.nullable(),
});
export function ExpandedPostFromRaw(raw: any): ExpandedPost {
const parsed = ExpandedPostValidator.parse(raw);
return parsed;
}
export function ExpandedPostQueryObjectFromRaw(
raw: any
): ExpandedPostQueryObject {
const parsed = ExpandedPostQueryObjectValidator.parse(raw);
return parsed;
}
Sometimes validators (schemas) reference other validators, so it helped to create the validators in logical order—from children to parents. During the process of writing the validators, I also realized that some of my TypeScript interfaces needed adjustments since they were initially requiring properties that wouldn’t always be present. Props to Zod for highlighting those oversights!
Finally, I implemented my new Zod parsing functions in the API calls, like so:
export async function getExpandedBlogPost(
slug: string
): Promise<ExpandedPost | null> {
const expandedBlogPostQuery = gql`
query GetExpandedBlogPost($slug: ID = "${slug}") {
post(id: $slug, idType: SLUG) {
databaseId
title
dateGmt
slug
author {
node {
databaseId
name
}
}
featuredImage {
node {
mediaItemUrl
altText
databaseId
}
}
content
}
}
`;
const variables = { slug };
const response = await request(
wpGraphQLBase,
expandedBlogPostQuery,
variables,
headers
);
const validatedResponse = ExpandedPostQueryObjectFromRaw(response);
const expandedPost = validatedResponse.post;
return expandedPost;
}
Now, if my GraphQL query doesn’t return results in the expected shape, Zod will catch it. That’s great news because it means Next.js can then return a 500 error to the client and handle the error gracefully. Cheers for graceful error handling!
The Little White Lie
And so all the API calls lived happily ever after.
Sort of.
I did run into a situation with combining Zod and Next.js that was surprisingly difficult to resolve—in fact, the best solution I could find involves lying to the client.
The BlogDetailPage
component is responsible for rendering individual blog posts. It calls getExpandedBlogPost()
to retrieve a specific post from WordPress:
export default async function BlogDetailPage(props: BlogDetailPageProps) {
const params = await props.params;
let post: ExpandedPost | null = null;
try {
post = await getExpandedBlogPost(params.slug);
} catch (error) {
return <ServerError />;
}
if (post === null) {
notFound();
}
return (
// render post
);
}
If someone requests a post that doesn’t exist, we want to return a 404 Not Found error. But if someone requests a post and Zod identifies a validation issue, we want to return a 500 Server Error instead, and we want the page shown to the user to be a custom error page rather than Next.js’s default white 500 page of hopelessness. Problem is, Next.js 15 doesn’t seem to support manually throwing 500 errors like it does 404 errors using notFound()
.
The above workaround is the best solution I could find, but it’s far from ideal since <ServerError />
is simply a custom component that looks like a 500 error page but returns a 200 HTTP response status code. It’s disingenuous and potentially confusing to users, but it’s a white lie that gets me the closest to achieving my desired UX.
So in summary, I’m now a pathological liar thanks to Next.js. At least Zod keeps me honest with my data.