Skip to content

Part 2.5 — Consuming REST APIs with Shared Types & Error Handling Strategies

Site Console Site Console
5 min read Updated Nov 29, 2025 Web Development 0 comments
Shared Types & Error Handling for React + REST APIs

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.ts

Your 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.ts

Example:

// 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

Leave a comment

Sign in to leave a comment.

Comments