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:
- Zero dependencies for the core fetch logic—it's just TypeScript
-
Simpler testing—mock a function that returns
ApiResponse<T>, not a whole library - Custom caching logic—I can add dedup, retry, or batch requests without fighting a library's opinions
- 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.
Top comments (0)