Building Robust Applications in React with TypeScript and Zod for REST API Validation
Published: 2024-05-23 16:54:55

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:

```tsx
// 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:

```json
{
  "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:

```vbnet
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: 
1. **Install Zod** :

```bash
npm install zod
``` 
2. **Define a Zod Schema** :

```tsx
// 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>;
``` 
3. **Validate API Response and Provide Feedback** :

```tsx
// 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;
};
``` 
4. **Handle Validation in the Component** :

```tsx
// 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:

```css
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:

```javascript
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.