Runtime Type Checking with Zod in a front-end application

TypeScript helps developers resolve common problems in web development by ensuring type safety. TypeScript performs type checking during compilation, allowing developers to catch potential issues early. However, when consuming data from external services like backend APIs or authentication services, we often take data structures for granted. If these services change their data types, it can break our front-end applications.

Frontend developers, unaware of such changes, may waste time debugging unrelated issues. The solution is to verify whether the response structure matches our expected type. Catching type mismatches early makes debugging much easier. Let’s explore this in practice using Next.js and Zod by building a simple weather application.

Weather App Overview

Here’s an example of a simple weather application:

Our folder structure:

The fake API is located in src/shared/api/weather/weather.service.ts

import { Weather } from "./weather.type";

export function fetchWeather(): Promise<Weather> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        temperature: 12,
        condition: "Windy",
        location: "London",
      });
    }, 1000);
  });
}

Our Weather component is defined in src/components/Weather.tsx:

import { fetchWeather } from "../shared/api/weather/weather.service";
import { Sun, Cloud, CloudRain, CloudSnow, Wind } from "lucide-react";
import type { Weather } from "@/shared/api/weather/weather.type";

const weatherIcons = {
  Sunny: Sun,
  Cloudy: Cloud,
  Rainy: CloudRain,
  Snowy: CloudSnow,
  Windy: Wind,
};

export default async function Weather() {

  const weather = await fetchWeather();

  const WeatherIcon = weatherIcons[weather.condition];

  return (
    <div className="bg-white p-6 rounded-lg shadow-md text-center">
      <h2 className="text-2xl font-bold mb-4">Weather in {weather.location}</h2>
      <div className="flex items-center justify-center mb-4">
        <WeatherIcon size={64} className="text-blue-500 mr-4" />
        <div>
          <p className="text-4xl mb-2">{weather.temperature}°C</p>
          <p className="text-xl">{weather.condition}</p>
        </div>
      </div>
    </div>
  );
}

And our page is on page.tsx :

import Weather from '../components/Weather';
export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-100">
      <h1 className="text-4xl font-bold mb-8 text-gray-800">Simple Weather App</h1>
      <Weather />
    </main>
  );

Handling Backend Data Mismatches

Imagine the backend sends incorrect data types or values, like:

{
  "temperature": "error",
  "condition": "windy",
  "location": "London"
}

This would cause issues in our application. For example, an invalid temperature or unrecognized condition might silently cause unexpected behavior:

Let’s assume we discover “windy” and run the project while ignoring any bugs, specifically the temperature value bug for now.

Temperature displayed “error” text. This is a bug. We can prevent these types of errors by implementing runtime type-checking

Adding Runtime Type Checking with Zod

To handle such issues, we can implement runtime type checking using Zod. Start by creating a schema for the expected response in

import { z } from "zod";

export const WeatherSchema = z.object({
  temperature: z.number(),
  condition: z.enum(["Snowy", "Windy", "Cloudy", "Sunny", "Rainy"]),
  location: z.string(),
});

You can learn more in the Zod’s documentation. Next, create a function in src/shared/utils/response.contract.ts to validate that the response matches the expected type:

import { ZodType } from "zod";

export function responseContract<Data>(schema: ZodType<Data>) {
  return (response: unknown): Data => {
    if (process.env.NODE_ENV === "production") {
      return response as Data;
    }
    const validation = schema.safeParse(response);
    if (validation.error) {
      throw new Error(validation.error.message);
    }
    return validation.data;
  };
}

Let’s use it in our weather service:

import { WeatherSchema } from "./weather.contracts";
import { responseContract } from "../../utils/response.contract";
import { Weather } from "./weather.type";

export function fetchWeather(): Promise<Weather> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        temperature: "error",
        condition: "Windy",
        location: "London",
      });
    }, 1000);
  }).then(responseContract(WeatherSchema)); // We add something here!
}

Let’s see how the error is shown to us:

Debugging is straightforward. We can see that the temperature is being represented as a string when we expect it to be a number, and “windy” is not a valid enum value. There is no need to search through various components to identify where the error occurred. This clarity is especially beneficial when frontend and backend development happen simultaneously.

This approach saves time by catching bugs early and enhances collaboration between frontend and backend teams. After identifying the error, we can either adjust our expectations or notify the backend developers if there is a bug on their end.

Thank you! Let me know if you need further adjustments!