When building applications with React and TypeScript, leveraging TypeScript's static typing capabilities can significantly enhance your code's reliability. However, even with TypeScript, you can't guarantee the shape and type of data coming from external APIs. This potential discrepancy can lead to runtime errors that disrupt your application's functionality. In this blog post, we'll explore how to handle such situations using Zod, a TypeScript-first schema declaration and validation library.
Scenario: Fetching User Data with TypeScript
Imagine you have a React application that fetches user data from an API. The User
object has a phoneNumber
property, which is expected to be a string. You'll format this phone number to display it in a user-friendly manner. Here's how you might start:
// types.ts
export interface User {
id: number;
name: string;
phoneNumber: string;
}
// api.ts
export const fetchUser = async (userId: number): Promise<User> => {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data: User = await response.json();
return data;
};
// UserComponent.tsx
import React, { useEffect, useState } from 'react';
import { User } from './types';
import { fetchUser } from './api';
const UserComponent: React.FC<{ userId: number }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(err => setError(err.message));
}, [userId]);
if (error) return <div>Error: {error}</div>;
if (!user) return <div>Loading...</div>;
const formattedPhoneNumber = formatPhoneNumber(user.phoneNumber);
return (
<div>
<h1>{user.name}</h1>
<p>Phone: {formattedPhoneNumber}</p>
</div>
);
};
const formatPhoneNumber = (phoneNumber: string): string => {
// Logic to format the phone number
return phoneNumber.replace(/(\d{3})(\d{3})(\d{4})/, '($1) $2-$3');
};
export default UserComponent;
In this example, we expect phoneNumber
to be a string. However, if the backend changes phoneNumber
to a number, this will lead to a runtime error in the formatPhoneNumber
function.
Runtime Error Example
Suppose the backend now sends:
{
"id": 1,
"name": "John Doe",
"phoneNumber": 1234567890
}
This change will cause a runtime error in the formatPhoneNumber
function because replace
is not a method on numbers. The error will look like this:
TypeError: phoneNumber.replace is not a function
This error indicates that phoneNumber
is not a string as expected, but a number, leading to the failure of the replace
method.
Introducing Zod for Schema Validation
To safeguard against such issues, we can use Zod to validate the data we receive from the API. Here's how we can do it step-by-step:
- Install Zod :
npm install zod
- Define a Zod Schema :
// schema.ts
import { z } from 'zod';
export const userSchema = z.object({
id: z.number(),
name: z.string(),
phoneNumber: z.string(),
});
export type User = z.infer<typeof userSchema>;
- Validate API Response and Provide Feedback :
// api.ts
import { userSchema, User } from './schema';
export const fetchUser = async (userId: number): Promise<User> => {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
const result = userSchema.safeParse(data);
if (!result.success) {
console.error("Validation Error:", result.error.format());
throw new Error('Invalid data format');
}
return result.data;
};
- Handle Validation in the Component :
// UserComponent.tsx
import React, { useEffect, useState } from 'react';
import { User } from './schema';
import { fetchUser } from './api';
const UserComponent: React.FC<{ userId: number }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(err => setError(err.message));
}, [userId]);
if (error) return <div>Error: {error}</div>;
if (!user) return <div>Loading...</div>;
const formattedPhoneNumber = formatPhoneNumber(user.phoneNumber);
return (
<div>
<h1>{user.name}</h1>
<p>Phone: {formattedPhoneNumber}</p>
</div>
);
};
const formatPhoneNumber = (phoneNumber: string): string => {
// Logic to format the phone number
return phoneNumber.replace(/(\d{3})(\d{3})(\d{4})/, '($1) $2-$3');
};
export default UserComponent;
In this updated example, the fetchUser
function uses the Zod schema to validate the API response. If the data doesn't match the expected schema, an error is thrown, and a detailed validation error is logged to the console. This feedback helps developers quickly identify and resolve issues with the API data.
Error Message Example
With the Zod validation in place, if the backend sends the phoneNumber
as a number, the application will catch this discrepancy and throw an error. The console will log a detailed error message like this:
Validation Error: {
"phoneNumber": {
"_errors": ["Expected string, received number"]
}
}
In addition to logging the error, the application will throw an error to the user with the message:
Error: Invalid data format
This message indicates to the user that there was an issue with the data received from the API.
Conclusion
Using Zod for schema validation in your React and TypeScript applications ensures that your application can gracefully handle unexpected changes in the API response structure. This approach helps you catch potential issues early, leading to more robust and reliable applications. By incorporating Zod into your development workflow, you can enhance your application's resilience against API changes and reduce runtime errors.
Remember, while TypeScript provides excellent compile-time type checking, runtime validation with tools like Zod is essential for dealing with external data sources. This combination ensures that your application remains stable and predictable, even when the data it relies on changes unexpectedly.