Building a Type-Safe REST API Client with TypeScript Discriminated Unions
The Problem: Type Safety Breaks at API Boundaries
If you've worked with TypeScript and REST APIs, you've probably written code like this:
async function fetchPosts() {
const response = await fetch('/api/posts');
const data = await response.json(); // ❌ Type: any
return data;
}
onst posts = await fetchPosts();
console.log(posts[0].titl); // Typo! Runtime error 💥
The moment you call response.json(), you lose all type safety. TypeScript can't help you, and bugs slip through to production.
The Solution: Type-Safe API Store with Discriminated Unions
I built a generic API client that maintains type safety throughout the entire request/response cycle. Here's what it looks like:
const response = await postStore.getAllItems('/api/posts');
if (isErrorResponse(response)) {
console.error(response.errorDetails); // string[]
return;
}
// ✅ TypeScript knows response.data is Post[]
console.log(response.data[0].title); // Full IntelliSense!
Let me show you how to build this.
Architecture Overview
The core consists of three components:
- Discriminated Union Types - Type-safe response handling
- Generic Store Class - Works with any data model
- Type Guards - Automatic type narrowing
1. Discriminated Union for API Responses
Instead of throwing exceptions, we return a discriminated union:
interface ApiResponseBase {
data: T | null;
status: number;
}
interface SuccessResponse extends ApiResponseBase {
type: 'success';
message: string;
}
interface ErrorResponse extends ApiResponseBase {
type: 'error';
errorDetails: string[];
}
export type ApiResponse = SuccessResponse | ErrorResponse;
Why this works:
- The
typefield is the discriminator - TypeScript can narrow the union based on
type - No more
try-catchblocks that lose type information
2. Generic Store Implementation
Here's the core getAllItems method:
export class MockAPIStore {
private data: T[] = [];
async getAllItems(endpoint: string): Promise<ApiResponse> {
try {
const response = await fetch(endpoint);
if (!response.ok) {
return {
type: 'error',
data: null,
status: response.status,
errorDetails: [`Failed to fetch: ${response.statusText}`],
};
}
const data = (await response.json()) as T[];
this.data = data;
return {
type: 'success',
data,
status: response.status,
message: `Successfully fetched ${data.length} items`,
};
} catch (error) {
return {
type: 'error',
data: null,
status: 0, // Network failure
errorDetails: [
error instanceof Error ? error.message : 'Network error'
],
};
}
}
}
Key Design Decisions:
✅ Status 0 for network failures - Distinguishes network issues from HTTP errors
✅ Generic constraint - T extends { id: number | string } ensures CRUD operations work
✅ No exceptions thrown - Always return a valid response object
✅ Type assertion - as T[] at the API boundary only
3. Type Guards for Clean Error Handling
Type guards enable TypeScript's control flow analysis:
export function isSuccessResponse(
response: ApiResponse
): response is SuccessResponse {
return response.type === 'success';
}
export function isErrorResponse(
response: ApiResponse
): response is ErrorResponse {
return response.type === 'error';
}
Now you can write clean, type-safe code:
const response = await postStore.getAllItems('/api/posts');
if (isErrorResponse(response)) {
// TypeScript knows: response.errorDetails exists
console.error(response.errorDetails.join(', '));
return;
}
// TypeScript knows: response.data is T[]
console.log(`Fetched ${response.data.length} posts`);
Complete CRUD Implementation
Utility Types for Create/Update
// Omit 'id' for creation (server generates it)
export type CreateItem = Omit;
// Require 'id', make other fields optional for updates
export type UpdateItem =
Pick & Partial<Omit>;
Create Method
async create(
item: CreateItem,
endpoint: string
): Promise<ApiResponse> {
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
};
try {
const response = await fetch(endpoint, options);
if (!response.ok) {
return {
type: 'error',
data: null,
status: response.status,
errorDetails: [`Failed to create: ${response.statusText}`],
};
}
const data = (await response.json()) as T;
this.data.push(data);
return {
type: 'success',
data,
status: response.status,
message: 'Item created successfully',
};
} catch (error) {
return {
type: 'error',
data: null,
status: 0,
errorDetails: [
error instanceof Error ? error.message : 'Network error'
],
};
}
}
Update Method
async updateItem(
endpoint: string,
item: UpdateItem
): Promise<ApiResponse> {
const options = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item),
};
try {
const response = await fetch(endpoint, options);
if (!response.ok) {
return {
type: 'error',
data: null,
status: response.status,
errorDetails: [`Failed to update: ${response.statusText}`],
};
}
const updatedItem = (await response.json()) as T;
this.data = this.data.map(existingItem =>
existingItem.id === updatedItem.id ? updatedItem : existingItem
);
return {
type: 'success',
data: updatedItem,
status: response.status,
message: 'Item updated successfully',
};
} catch (error) {
return {
type: 'error',
data: null,
status: 0,
errorDetails: [
error instanceof Error ? error.message : 'Network error'
],
};
}
}
Real-World Usage Example
interface Post {
id: number;
userId: number;
title: string;
body: string;
}
const postStore = new MockAPIStore();
async function managePosts() {
// Fetch all posts
const allPosts = await postStore.getAllItems(
'https://jsonplaceholder.typicode.com/posts'
);
if (isErrorResponse(allPosts)) {
console.error('Failed to fetch posts:', allPosts.errorDetails);
return;
}
console.log(`Loaded ${allPosts.data.length} posts`);
// Create new post
const newPost = {
userId: 1,
title: 'Type-Safe APIs Rock!',
body: 'No more runtime errors...'
};
const created = await postStore.create(
newPost,
'https://jsonplaceholder.typicode.com/posts'
);
if (isErrorResponse(created)) {
console.error('Create failed:', created.errorDetails);
return;
}
console.log('Created post:', created.data.title);
// Update post
const updated = await postStore.updateItem(
`https://jsonplaceholder.typicode.com/posts/${created.data.id}`,
{
id: created.data.id,
title: 'Updated Title'
}
);
if (isSuccessResponse(updated)) {
console.log('Updated:', updated.data.title);
}
}
Benefits of This Approach
1. Compile-Time Safety
const response = await postStore.getAllItems('/api/posts');
if (isSuccessResponse(response)) {
// ✅ TypeScript knows response.data is Post[]
response.data.forEach(post => {
console.log(post.title); // IntelliSense works!
});
}
2. Exhaustive Error Handling
// TypeScript forces you to handle both cases
function handleResponse(response: ApiResponse) {
if (isSuccessResponse(response)) {
return response.data;
} else if (isErrorResponse(response)) {
return [];
}
// If you forget a case, TypeScript complains!
}
3. Better Error Messages
if (isErrorResponse(response)) {
console.error('Errors:', response.errorDetails);
// ["Failed to fetch: 404 Not Found"]
if (response.status === 0) {
console.log('Network failure - check your connection');
} else if (response.status >= 500) {
console.log('Server error - try again later');
}
}
4. Generic Reusability
// Works with any model!
interface User { id: string; name: string; email: string; }
interface Product { id: number; name: string; price: number; }
const userStore = new MockAPIStore();
const productStore = new MockAPIStore();
// Both get full type safety automatically
Alternative Patterns & Comparisons
vs. Try-Catch
// ❌ Traditional try-catch loses type info
try {
const data = await fetchPosts();
console.log(data); // Type: any
} catch (error) {
console.error(error); // Type: unknown
}
// ✅ Discriminated union preserves types
const response = await postStore.getAllItems('/posts');
if (isErrorResponse(response)) {
console.error(response.errorDetails); // Type: string[]
} else {
console.log(response.data); // Type: Post[]
}
vs. Libraries (Axios, TanStack Query)
When to use this pattern:
- Learning TypeScript advanced features
- Small-to-medium projects
- No need for caching/retry logic
- Want zero dependencies
When to use libraries:
- Need advanced features (caching, retries, deduplication)
- Large enterprise applications
- Team already familiar with the library
Testing Strategy
import { describe, it, expect } from 'vitest';
describe('MockAPIStore', () => {
it('handles successful requests', async () => {
const store = new MockAPIStore();
const response = await store.getAllItems('https://api.example.com/items');
expect(isSuccessResponse(response)).toBe(true);
if (isSuccessResponse(response)) {
expect(Array.isArray(response.data)).toBe(true);
}
});
it('handles network errors', async () => {
const store = new MockAPIStore();
const response = await store.getAllItems('https://invalid-domain-xyz.com');
expect(isErrorResponse(response)).toBe(true);
if (isErrorResponse(response)) {
expect(response.status).toBe(0);
expect(response.errorDetails.length).toBeGreaterThan(0);
}
});
});
Performance Considerations
Local State Sync
The store maintains an in-memory cache:
private data: T[] = [];
async getAllItems(endpoint: string): Promise<ApiResponse> {
// ... fetch logic
this.data = data; // ✅ Cache locally
return { type: 'success', data, ... };
}
Trade-offs:
- ✅ Fast subsequent reads
- ✅ Supports offline-first patterns
- ⚠️ Memory usage scales with data size
- ⚠️ Can get out of sync with server
Future Enhancement: Add TTL (time-to-live) for cache invalidation.
Lessons Learned
1. Discriminated Unions > Exceptions
Exceptions break type flow. Discriminated unions preserve it.
2. Generic Constraints Are Powerful
T extends { id: number | string }
This one constraint prevents countless runtime errors.
3. Type Guards Enable Clean Code
Without type guards:
if (response.type === 'success') {
console.log((response as SuccessResponse).data); // Ugly!
}
With type guards:
if (isSuccessResponse(response)) {
console.log(response.data); // Clean!
}
4. Status Code Conventions Matter
-
0= Network failure -
200-299= Success -
400-499= Client error -
500-599= Server error
Consistent conventions make debugging easier.
What's Next?
Potential enhancements for v2:
- [ ] Request caching with TTL
- [ ] Retry logic with exponential backoff
- [ ] Request cancellation (AbortController)
- [ ] Optimistic updates with rollback
- [ ] React hooks integration (
useQuery,useMutation) - [ ] WebSocket support for real-time data
Conclusion
Building type-safe API clients isn't just about preventing bugs—it's about making bugs impossible to write in the first place.
Key takeaways:
✅ Discriminated unions preserve type information through async flows
✅ Generic constraints enforce structure at compile time
✅ Type guards enable clean, readable code
✅ Zero dependencies means zero maintenance burden
The complete source code is available on GitHub: [your-repo-link]
What patterns do you use for type-safe API communication? Share your approaches in the comments!
Tags: #typescript #webdev #javascript #tutorial #frontend #api #programming
Connect with me:
If this article helped you, please consider giving it a ❤️ and sharing it with your network!
Top comments (0)