Part 2.5 — Consuming REST APIs with Shared Types & Error Handling Strategies
When your frontend and backend speak the same language, development speeds up and bugs drop dramatically.
In this final post of Part 2, you’ll learn how to:
Share TypeScript types between React and NestJS
Build a unified API layer with typed responses
Handle errors predictably
Detect network vs. server vs. validation issues
Prepare your React app for Part 3’s NestJS + Prisma implementation
This is where your full-stack architecture starts to feel truly cohesive.
1. Why Shared Types Matter
Without shared types, you’re guessing what the backend sends back.
Guessing leads to:
Breaking changes
UI crashes
Wrong assumptions
Hours lost debugging mismatched shape
Shared types turn your API into a contract both sides must honor.
In the monorepo layout:
packages/
types/
src/
user.ts
post.ts
dto/
create-user.ts
update-post.tsYour frontend imports from here; your NestJS backend will also import the same types in Part 3.
2. Example Shared Types
// packages/types/src/post.ts
export interface Post {
id: number;
title: string;
body: string;
createdAt: string;
}DTOs:
// packages/types/src/dto/create-post.ts
export interface CreatePostDto {
title: string;
body: string;
}Your backend will later validate these with class-validator.
Your frontend already knows exactly what’s expected.
3. Build a Typed API Client (Never Repeat Fetch Logic)
Instead of chaining raw fetch everywhere, use a central HTTP helper:
// src/lib/http.ts
export interface ApiError {
status: number;
message: string;
details?: unknown;
}
export async function http<T>(url: string, options?: RequestInit): Promise<T> {
try {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
let details: unknown = undefined;
try {
details = await res.json();
} catch {
// ignore parse failures
}
const err: ApiError = {
status: res.status,
message: res.statusText,
details,
};
throw err;
}
return res.json() as Promise<T>;
} catch (err) {
if (err instanceof TypeError) {
// Network failure or CORS problem
throw {
status: 0,
message: 'Network error',
} as ApiError;
}
throw err;
}
}Why this works:
Every request returns a typed result
Every failure becomes a typed ApiError
No component needs to track status codes
No duplicated fetch boilerplate
4. Handling Errors in React Components
A component can now handle all failure modes:
import type { ApiError } from '../lib/http';
try {
const posts = await getPosts();
} catch (err) {
const error = err as ApiError;
if (error.status === 0) {
setError('Network unavailable. Try again.');
} else if (error.status === 400) {
setError('Invalid input.');
} else if (error.status === 404) {
setError('Resource not found.');
} else {
setError('Unexpected error occurred.');
}
}Explicit logic → predictable UX.
5. Build a Retry-Ready Wrapper Hook
Instead of repeating this everywhere, you can encapsulate error and loading state:
// src/hooks/useApi.ts
import { useState } from 'react';
import type { ApiError } from '../lib/http';
export function useApi<T>(fn: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<ApiError | null>(null);
const run = async () => {
setLoading(true);
setError(null);
try {
const result = await fn();
setData(result);
} catch (err) {
setError(err as ApiError);
} finally {
setLoading(false);
}
};
return { data, loading, error, run };
}Usage:
const { data: posts, loading, error, run } = useApi(getPosts);
useEffect(() => {
run();
}, []);Now any API call can be wrapped in clean, predictable state management.
6. Detect Backend Validation Errors (Important for Part 3)
When your NestJS backend rejects invalid DTOs, it responds with something like:
{
"statusCode": 400,
"message": ["title must not be empty", "email must be valid"],
"error": "Bad Request"
}Your client should gracefully surface this.
if (error?.status === 400 && Array.isArray(error.details?.message)) {
return (
<ul className="text-red-500 text-sm">
{error.details.message.map((msg: string) => (
<li key={msg}>{msg}</li>
))}
</ul>
);
}A user sees helpful, field-specific messages — not a vague “something broke.”
7. Build an API Module Per Resource
Organize data access like this:
src/api/
posts.ts
users.ts
auth.ts
comments.tsExample:
// src/api/posts.ts
import { http } from '../lib/http';
import type { Post } from '@types/post';
import type { CreatePostDto } from '@types/dto/create-post';
export function getPosts() {
return http<Post[]>('/api/posts');
}
export function createPost(dto: CreatePostDto) {
return http<Post>('/api/posts', {
method: 'POST',
body: JSON.stringify(dto),
});
}This builds a clean, scalable data layer.
8. Use Runtime Validation for Safety (Optional but Powerful)
Sometimes the backend returns malformed data or unexpected shapes (especially during early development).
You can validate responses using Zod.
import { z } from 'zod';
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
createdAt: z.string(),
});
const PostsSchema = z.array(PostSchema);
export async function getPostsSafe() {
const data = await http<unknown>('/api/posts');
return PostsSchema.parse(data);
}This guarantees your UI never receives corrupted values.
9. Combine Everything Into a Real Component
export function PostsPage() {
const { data, loading, error, run } = useApi(getPosts);
useEffect(() => {
run();
}, [run]);
if (loading) return <p>Loading…</p>;
if (error)
return (
<div>
<p className="text-red-500">{error.message}</p>
<button onClick={run} className="text-blue-500 underline">
Retry
</button>
</div>
);
if (!data || data.length === 0) return <p>No posts yet.</p>;
return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}This page gracefully handles every possible outcome.
10. Why This Matters for the Backend (Part 3)
In the next part, you’ll build a NestJS backend with:
Prisma models
DTO validation
REST controllers
Error filters
Database persistence
Your frontend is now ready:
typed, predictable, error-safe, and aligned with the contracts your backend will enforce.
Related
Part 2.4 — UX State: Loading, Error, and Empty States in React
A React app becomes truly usable when it handles all states gracefully: loading, error, empty, and success. In this post, you’ll learn the UX patterns professionals rely on.
Part 2.3 — Handling Forms, Validation & Submission to REST Endpoints
Forms connect users to data. In this post, you’ll learn how to validate input, manage UX states, and submit cleanly typed data to real REST endpoints.
Part 2.2 — Building CRUD Interfaces: Posts, Users, and API Contracts
CRUD is the backbone of nearly every full-stack app. In this post, you’ll build clean React CRUD interfaces that match real REST API contracts you’ll implement in NestJS and Prisma later.
Comments