Improving Type Definitions in React with TypeScript & Zod!

Improving Type Definitions in React with TypeScript & Zod!

·

8 min read

Hello there 👋

If you're on the journey of learning TypeScript and looking to get better at defining types. You're in the right spot.

Today, we're gonna dive into improving type definitions using Zod, especially in a React environment.

As frontend devs, we deal with data, and it comes from everywhere, like APIs, local storage, forms, etc.

Making sure this data is reliable and solid is a big deal, and that's where TypeScript comes into play, enabling us to define models and provide neat features like autocompletion.

However, challenges arise:

  • What if the data changes shape when we least expect it, slipping through without us noticing?

  • How do we defend our apps from inconsistencies, ensuring our type definitions remain robust?

We’ll tackle these questions and explore strategies to make our dev life a bit smoother.

"Talk is cheap. Show me the code." - Linus Torvalds

And show you, I will! Let's rock and roll with the code, shall we?

Configuring the Next.js Project

Let's dive right into creating a Next.js project, where we'll employ TypeScript, Tailwindcss, the App router, and set the default alias as @/*. Don't worry about other configurations for now; they aren’t crucial for this example.

Next, let’s install the dependencies. Choose your favorite package manager to do so. Throughout this article, I’ll be using Bun just for fun.

Create the project:

bun create next-app

Install Zod:

bun install zod

Install necessary packages:

bun install

Time to get our project off the ground:

bun dev

With our project up and running, let's tidy up the main page a bit.

Navigate to app/index.tsx and modify it as follows:

import Image from 'next/image'

export default function Home() {
  return (
     <main className="flex min-h-screen flex-col items-center justify-between p-24 bg-white text-gray-900">
      <p>Hello World</p>
    </main>
  )
}

Setting Up the Endpoint

Great, with our project up and running, it's endpoint creation time!

Example Scenario: Transitioning User Role Representation

Imagine a scenario where our social media platform manages user data. Initially, our platform defined a user model that simply outlines fields like 'id', as a number and 'name', 'email', and 'role' as strings.

In the latest Next.js version, route files could enable us to create custom request handlers for specific routes.

Route Handlers allow you to create custom request handlers for a given route using the Web Request and Response APIs.

📚 Learn more here

Now, let's create our route, mockingly assigning values for a user.
Create app/api/users/route.ts and modify it as follows:

import { NextResponse } from 'next/server'

export const GET = async () => {
  const user = {
    id: 1,
    name: 'John Doe',
    email: 'john@doe.com',
    role: 'admin',
  }

  return NextResponse.json(user)
}

With these steps, our endpoint takes shape. Now, let's shift our focus and use it.

Engaging the Frontend: Fetching and Displaying User Data

Let's start creating a data model to assure consistency. Next, we'll fetch and showcase our data!

'use client'
import * as React from 'react'

type User = {
  id: number
  name: string
  email: string
  role: string
}

const HomePage = () => {
  const [user, setUser] = React.useState<User | null>(null)

  React.useEffect(() => {
    const fetchUser = async () => {
      const response = await fetch('/api/user')
      const data: User = await response.json()
      setUser(data)
    }

    fetchUser()
  }, [])

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24 bg-white text-gray-900">
      {user ? (
        <div className="space-y-2">
          <h1 className="text-4xl font-bold">Welcome {user.name}</h1>
          <p className="text-2xl">Your email is: {user.email}</p>
          <p className="text-2xl">
            Your role is: {user.role.toLocaleUpperCase()}
          </p>
        </div>
      ) : (
        <p>Loading user</p>
      )}
    </main>
  )
}

export default HomePage

And run!

All Looks Good... Until it Doesn’t!

The Plot Twist: Backend Changes the Game

So far, so good! Our user data is being displayed perfectly on our page. But wait – what happens if something changes on the backend?

Let’s keep in mind that often, especially in the realm of frontend development, the backend alterations occur beyond our control - perhaps due to an external API or another team’s backend modifications, which may change the data model without prior notice to the frontend team.

What if, instead of sending a single role as a string, it sends multiple roles within an array?

import { NextResponse } from 'next/server'

export const GET = async () => {
  const user = {
    id: 1,
    name: 'John Doe',
    email: 'john@doe.com',
    roles: ['admin', 'editor', 'subscriber'],
  }

  return NextResponse.json(user)
}

A wild undefined error appeared:

TypeError: Cannot read properties of undefined (reading 'toLocaleUpperCase').

This error occurs when we try to invoke a method or access a property on undefined. It's a common JavaScript runtime error that points out a misalignment between our expected data shape and what’s coming through.

In our code:

Your role is {user.role.toLocaleUpperCase()}

The code anticipates that user.role is always defined and is a string. If role is undefined, attempting to call toLocaleUpperCase() on it will throw the aforementioned error.

In some cases a quick patch might involve the Optional Chaining (?. )operator:

Your role is {user.role?.toLocaleUpperCase()}

Yet, employing Optional Chaining operator (?.) might offer a quick fix, it merely masks the issue rather than addressing it, leading to silent failures and allowing issues to proliferate undetected.

Why?

Because if role is undefined, it doesn’t trigger an error or provide a fallback - it just silently returns undefined, and our UI would not render the expected data, leading to potential user confusion and degraded user experience.

In the robust frontend development realm, our goal should be error handling that’s both anticipative and diagnostic, aiming to:

  1. Anticipate potential pitfalls - validating data structures, types, and shapes, especially when interfacing with external APIs or backend services.

  2. Diagnose issues expediently - logging errors or inconsistencies in a way that facilitates speedy debugging and minimal disruption to the user experience.

How I sound to my customers when we can't reproduce their "bug" :  r/ProgrammerHumor

Let's see how Zod can assist in pre-emptively catching such discrepancies!

Introducing Zod

Here's where Zod will shine!

But first, what is Zod?

Dipping into the documentation, we find that Zod is defined as a

Zod is a TypeScript-first schema declaration and validation library. I'm using the term "schema" to broadly refer to any data type, from a simple string to a complex nested object.

Breaking that down a bit: Zod is a toolkit specifically built with TypeScript in mind, aimed at both declaring and validating schemas. Imagine a "schema" as a blueprint for data, which can range from a basic string to an elaborate nested object.

The real beauty of Zod lies in its devotion to developer-friendliness. You declare a validator just once, and Zod automatically takes on the task of inferring the TypeScript type.

Zod to the Rescue

My Time Has Come - Meming Wiki

Now, fellow developers, it’s your moment to shine alongside Zod!

Shall we?

Crafting the User Schema

import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
  roles: z.array(z.string()),
})

In this schema:

  • z.number(), z.string(), and z.array(z.string()) are Zod validators that ensure id is a number, name and email are strings, and roles is an array of strings respectively.

Simple right?

Automatically Creating Types

type User = z.infer<typeof UserSchema>

Zod’s infer creates a User type automatically from UserSchema, ensuring our user data has the right shape. It automatically generates a TypeScript type (User) based on our UserSchema. So, without manually writing the type, we know what shape of data to expect and enjoy TypeScript’s benefits of type-checking and autocompletion.

Data Shape Guarantee with Zod

When you have a Zod schema, you can use its .parse method to check your data. If the data is in the right format, it gives you back the data with all its type information. If the data is in the wrong format, it throws an error right away

Alternatively, .safeParse might be your ally if you prefer to sidestep errors being thrown, offering a gentler approach.

📚 Dive deeper into these methods here.

Tackling Challenges with Zod

Time to piece it all together!

'use client'
import * as React from 'react'

import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
  roles: z.array(z.string()),
})

type User = z.infer<typeof UserSchema>

const HomePage = () => {
  const [user, setUser] = React.useState<User | null>(null)

  React.useEffect(() => {
    const fetchUser = async () => {
      const response = await fetch('/api/user')
      const validateUser = UserSchema.safeParse(await response.json())

      if (validateUser.success) {
        setUser(validateUser.data)
      } else {
        console.error(validateUser.error)
      }
    }

    fetchUser()
  }, [])

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24 bg-white text-gray-900">
      {user ? (
        <div className="space-y-2">
          <h1 className="text-4xl font-bold">Welcome {user.name}</h1>
          <p className="text-2xl">Your email is: {user.email}</p>
          <p className="text-2xl uppercase">
            Your roles are: {user.roles.join(', ')}
          </p>
        </div>
      ) : (
        <p>Loading user</p>
      )}
    </main>
  )
}

export default HomePage

"What if the backend unexpectedly alters the data shape and returns roles as a string instead of an array?

No worries. Zod will catch this inconsistency and return a well-structured error.

Then we can handle this validation failure gracefully.

Conclusions

In the frontend development, data reliability is key. Zod offers developers a safeguard, ensuring TypeScript applications remain robust against type discrepancies and data inconsistencies by validating data and autonomously generating TypeScript types.

Thank you for reading! Your feedback is vital to our community's growth and learning. Please share your thoughts or challenges in the comments below, and let’s navigate the coding journey together.

Make the Code be with you.