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:
Anticipate potential pitfalls - validating data structures, types, and shapes, especially when interfacing with external APIs or backend services.
Diagnose issues expediently - logging errors or inconsistencies in a way that facilitates speedy debugging and minimal disruption to the user experience.
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
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()
, andz.array(z.string())
are Zod validators that ensureid
is a number,name
andemail
are strings, androles
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.