iTranslated by AI
Exploring Runtime Validation for API Responses in Frontend Applications
Introduction
As frontend developers, we often struggle with how to define response types from APIs. How are you all handling this?
In this article, I would like to introduce one solution for those who are struggling with type definitions.
The Problem
// fetch returns any or Promise<any> by default
const data = await response.json(); // any type...😵💫
You can apply types using assertions like as User[], but there is no guarantee that the data returned from the server at runtime actually matches that type. This is because TypeScript types are erased at runtime.😶🌫️
Why is Runtime Validation Necessary?
type User = {
id: number;
name: string;
email: string;
};
const response = await fetch('/api/users');
const user = (await response.json()) as User;
console.log(user.email.toLowerCase()); // Error if email is missing
In the case above, if the response from the server is missing the email field, it will lead to a runtime error.
Common Runtime Error Scenarios
API Specification Changes
🫤 "Wait, did the API spec change?" (Asked, but maybe forgot)
Handling null/undefined
🧐 "Wait... eh... why?" "Ah, that field returns null."
Type Mismatch
🤨 "Why is it like this when the number 123 is supposed to be returned..."
(Spec where a number is returned as a string: "123" instead of 123)
Additional Fields
🙄 "Hmm, something was added?" Same as spec changes
By implementing runtime validation to detect these issues at runtime and handle them appropriately, you can compensate for the constraint that TypeScript types are erased at runtime.
Schema Definition and Validation
Below, I use Zod as a representative example, but lightweight alternatives like Valibot are also fine.
Basic Usage (or the conclusion)
import { z } from 'zod';
// Schema definition
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
// Infer type from Schema
type User = z.infer<typeof UserSchema>;
// Execute validation
const result = UserSchema.safeParse(responseData);
if (result.success) {
// result.data is type-safe as User type
console.log(result.data.email);
} else {
// Get details of validation error
console.error(result.error.issues);
}
Differences between parse and safeParse
// parse: Throws an exception on failure
try {
const user = UserSchema.parse(responseData);
} catch (e) {
if (e instanceof z.ZodError) {
console.error(e.issues);
}
}
// safeParse: Returns a Result type even on failure (does not throw an exception)
const result = UserSchema.safeParse(responseData);
if (!result.success) {
console.error(result.error.issues);
}
Example for Arrays or Nested Objects
// Array
const UsersResponseSchema = z.array(UserSchema);
type UsersResponse = z.infer<typeof UsersResponseSchema>;
// Nested
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
author: UserSchema,
tags: z.array(z.string()),
});
Schema Generation from OpenAPI with Orval
Converting a Zod Schema into a type using infer as shown above is fine, but writing schemas manually is tedious, and keeping up with API spec changes is difficult.
It is quite famous and perhaps doesn't need much introduction, but if you use Orval, you can automatically generate Zod Schemas from OpenAPI (yaml/json) files.
Orval Configuration
// orval.config.ts
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
input: {
target: './openapi.yaml', // OpenAPI spec file
},
output: {
mode: 'tags-split',
client: 'zod', // Output only zod Schemas
target: 'src/gen/schemas',
fileExtension: '.zod.ts',
},
},
});
Generated Schema
// src/gen/schemas/users.zod.ts (Auto-generated)
import { z as zod } from 'zod';
export const getUsersResponseItem = zod.object({
id: zod.number(),
name: zod.string(),
email: zod.string(),
});
export const getUsersResponse = zod.array(getUsersResponseItem);
export const postUsersBody = zod.object({
name: zod.string(),
email: zod.string(),
});
Why not fully utilize Orval to also generate fetch clients?
Orval can generate typed clients using client: 'fetch' or client: 'react-query', but runtime validation of responses is not performed automatically.
Generated clients only provide type inference, so they cannot detect invalid data returned from the server at runtime. Since this falls short of a fundamental solution, I recommend a configuration where only Zod Schemas are generated, while fetch clients are prepared manually to execute safeParse at the boundary layer.
The Significance of Dividing Layers
By executing validation at the Boundary of the application, internal business logic can always handle verified data.
Implementation Example: Designing the API Client as a Boundary Layer
// api/client.ts (Boundary Layer)
import { type ZodSchema } from 'zod';
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: { type: 'validation' | 'network'; message: string } };
class ApiClient {
private baseURL: string;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
/**
* Fetch as a Boundary layer
* - Validate external data by running safeParse with Orval-generated schemas
* - Pass only validated data internally
*/
async fetch<T>(
endpoint: string,
schema: ZodSchema<T>
): Promise<ApiResult<T>> {
try {
const response = await fetch(`${this.baseURL}${endpoint}`);
if (!response.ok) {
return {
success: false,
error: { type: 'network', message: `HTTP ${response.status}` },
};
}
const json = await response.json();
// Boundary: Validate external data (safeParse does not throw exceptions)
const result = schema.safeParse(json);
if (!result.success) {
console.error('Validation failed:', result.error.issues);
return {
success: false,
error: { type: 'validation', message: 'Invalid response format' },
};
}
return { success: true, data: result.data };
} catch (error) {
return {
success: false,
error: { type: 'network', message: error instanceof Error ? error.message : 'Unknown error' },
};
}
}
}
export const apiClient = new ApiClient('https://api.xxx.xxxx');
// services/userService.ts (Internal: Handling only verified data)
import { z } from 'zod';
import { apiClient } from '../api/client';
// Import Schema generated by Orval
import { getUsersResponse } from '../gen/schemas/users.zod';
type User = z.infer<typeof getUsersResponse>[number];
export async function getUsers(): Promise<User[] | null> {
// Pass the Orval-generated schema
const result = await apiClient.fetch('/users', getUsersResponse);
if (!result.success) {
// Error handling (logging, notifications, etc.)
return null;
}
// result.data is validated User[]
return result.data;
}
By using safeParse in the Boundary layer and returning a Result type, error handling is explicitly forced at the caller side, preventing try-catch blocks from becoming deeply nested. You should adapt this based on the nature and constraints of the app you are building.
Type and Value Conversion
Since this is processing on the client side, it is not desirable to change response types as significantly as a BFF (Backend For Frontend) might, but a benefit is being able to transform data into a form that is easier to handle on the frontend.
:::note info
Also, since the Schema generated by Orval is based on OpenAPI definitions, dates will be string type. Use this technique if you want to convert them to Date objects.
:::
import { z } from 'zod';
import { getUsersIdResponse } from '../gen/schemas/users.zod';
// Extend generated Schema to convert to Date
const UserWithDateSchema = getUsersIdResponse.extend({
createdAt: z.string().datetime().transform((str) => new Date(str)),
});
type UserWithDate = z.infer<typeof UserWithDateSchema>;
// UserWithDate['createdAt'] is Date type
Configuration Example
src/
├── gen/ # Auto-generated by Orval (do not edit)
│ └── schemas/
│ └── users.zod.ts
├── api/
│ └── client.ts # Boundary layer (fetch + safeParse)
├── services/
│ └── userService.ts # Internal logic
└── components/
└── UserList.tsx # UI
Summary
I have explored a way to resolve the frustrations with any types that can be introduced into existing projects without making breaking changes. By explicitly assigning a role as a Boundary layer and introducing runtime validation, I believe it becomes possible to build a robust frontend that is resilient to API specification changes.
Discussion