Skip to main content
All posts

TypeScript Generics for Polymorphic API Responses: Building Type-Safe Client Code Without Response Union Hell

6 June 2026

TypeScript Generics for Polymorphic API Responses: Building Type-Safe Client Code Without Response Union Hell

I’ve built CitizenApp with 9 different AI features, each with its own API endpoints. Early on, I made the mistake every full-stack developer makes: I wrote a different fetch wrapper for every endpoint. One for user creation, one for AI analysis, one for billing webhooks. Each had its own type guards, its own error handling, its own loading state logic.

Six months in, I realized I’d written the same if (response.ok) check about 47 times.

That’s when I discovered that constrained TypeScript generics can eliminate this entirely. Not just make it prettier—actually eliminate it. You write the fetch logic once, and every endpoint inherits proper typing, error handling, and loading states automatically.

Here’s how.

The Problem: Response Union Hell

Before generics, this is what my code looked like:

// Bad: Writing this for EVERY endpoint
async function fetchUserData(userId: string) {
  const response = await fetch(`/api/users/${userId}`);
  
  if (!response.ok) {
    return { success: false, error: response.statusText };
  }
  
  const data = await response.json();
  return { success: true, data: data as UserResponse };
}

async function fetchAIAnalysis(prompt: string) {
  const response = await fetch(`/api/ai/analyze`, {
    method: 'POST',
    body: JSON.stringify({ prompt }),
  });
  
  if (!response.ok) {
    return { success: false, error: response.statusText };
  }
  
  const data = await response.json();
  return { success: true, data: data as AIAnalysisResponse };
}

// ...repeated 20 more times

The real problem isn’t duplication—it’s fragility. If my FastAPI endpoint changes its response schema, I don’t find out until runtime. And if I want to add retry logic or request deduplication, I have to modify every single wrapper.

The Solution: Generic Response Wrapper Pattern

Here’s what I use now in CitizenApp. It’s a single, constraint-based generic function that works for every endpoint:

// types.ts
export interface ApiSuccess<T> {
  success: true;
  data: T;
  status: number;
}

export interface ApiError {
  success: false;
  error: string;
  status: number;
  details?: Record<string, unknown>;
}

export type ApiResponse<T> = ApiSuccess<T> | ApiError;

// This constraint ensures we only accept valid JSON-serializable types
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };

Now the actual fetch wrapper:

// api-client.ts
export async function apiFetch<T extends JsonValue>(
  endpoint: string,
  options?: RequestInit
): Promise<ApiResponse<T>> {
  try {
    const response = await fetch(endpoint, {
      headers: {
        'Content-Type': 'application/json',
        ...options?.headers,
      },
      ...options,
    });

    const data = await response.json();

    if (!response.ok) {
      return {
        success: false,
        error: data.detail || response.statusText,
        status: response.status,
        details: data,
      };
    }

    return {
      success: true,
      data: data as T,
      status: response.status,
    };
  } catch (err) {
    return {
      success: false,
      error: err instanceof Error ? err.message : 'Unknown error',
      status: 0,
    };
  }
}

That’s it. One function. Let me show you why this changes everything.

Type Inference from FastAPI Schema

On the backend, I define my FastAPI models:

# backend/schemas.py
from pydantic import BaseModel

class UserResponse(BaseModel):
    id: str
    email: str
    name: str
    created_at: datetime

class AIAnalysisResult(BaseModel):
    analysis: str
    confidence: float
    tokens_used: int

Then I use a tool like openapi-typescript to generate TypeScript types directly from my OpenAPI schema:

npx openapi-typescript http://localhost:8000/openapi.json -o api-types.ts

Now in my React components, the generic receives the actual type from my API:

// components/UserProfile.tsx
import { ApiResponse, apiFetch } from '@/api-client';
import type { UserResponse } from '@/api-types';

export default function UserProfile({ userId }: { userId: string }) {
  const [response, setResponse] = useState<ApiResponse<UserResponse> | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    apiFetch<UserResponse>(`/api/users/${userId}`)
      .then(setResponse)
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  
  if (!response?.success) {
    return <div>Error: {response?.error}</div>;
  }

  // TypeScript KNOWS response.data is UserResponse here
  // No type guard needed—it's inferred from the generic
  return (
    <div>
      <h1>{response.data.name}</h1>
      <p>{response.data.email}</p>
      <time>{new Date(response.data.created_at).toLocaleDateString()}</time>
    </div>
  );
}

See what happened? I didn’t write a separate wrapper for fetchUserData. I didn’t write a type guard. TypeScript inferred the entire response shape from the generic argument.

The Real Power: Request Deduplication

Now that I have a single entry point, I can add cross-cutting concerns that affect every API call:

// api-client.ts with deduplication
const requestCache = new Map<string, Promise<ApiResponse<any>>>();

export async function apiFetch<T extends JsonValue>(
  endpoint: string,
  options?: RequestInit
): Promise<ApiResponse<T>> {
  // Only deduplicate GET requests
  const cacheKey = options?.method ? endpoint : `${endpoint}:${JSON.stringify(options)}`;
  
  if (requestCache.has(cacheKey)) {
    return requestCache.get(cacheKey)!;
  }

  const promise = performFetch<T>(endpoint, options);
  
  if (!options?.method || options.method === 'GET') {
    requestCache.set(cacheKey, promise);
    setTimeout(() => requestCache.delete(cacheKey), 5000); // 5s cache
  }

  return promise;
}

async function performFetch<T extends JsonValue>(
  endpoint: string,
  options?: RequestInit
): Promise<ApiResponse<T>> {
  // ... original fetch logic
}

Now every endpoint automatically gets request deduplication. If two components fetch /api/users/123 simultaneously, they share a single network request. No modifications needed on the component side.

Gotcha: Generic Constraints Can Be Too Tight

I initially constrained the generic to only JsonValue types. This bit me when I tried to return a Date object:

// This fails at runtime because Date isn't JSON-serializable
class UserResponse {
  createdAt: Date; // ❌ Will become a string, then cause issues
}

FastAPI returns ISO strings, so the constraint is actually correct—but it felt unintuitive. I added a comment in my codebase explaining this:

// NOTE: T must be JSON-serializable. If your response includes Date,
// DateTime, or other non-JSON types, either:
// 1. Return them as ISO strings from FastAPI (preferred)
// 2. Transform them after receiving: new Date(response.data.createdAt)

Why This Beats Alternatives

I considered using TanStack Query (React Query), but for CitizenApp I preferred this pattern because:

  1. Zero dependencies for the core fetch logic—it’s just TypeScript
  2. Simpler testing—mock a function that returns ApiResponse<T>, not a whole library
  3. Custom caching logic—I can add dedup, retry, or batch requests without fighting a library’s opinions
  4. Learning value—understanding generics is more valuable than relying on React Query’s magic

For larger teams, React Query might be the right call. But for building fast, I take the generic approach every time.

What I Missed

I spent weeks building this pattern before realizing I could generate the types directly from my FastAPI schema. That 30-second openapi-typescript command eliminated SO much manual type-writing. If you’re not auto-generating client types from your API schema, you’re adding unnecessary friction.

Start there.

You might also like

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch