DEV Community

Abdullah Tariq
Abdullah Tariq

Posted on

Building a Type-Safe REST API Client with TypeScript Discriminated Unions

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;
}
const posts = await fetchPosts();
console.log(posts[0].titl); // Typo! Runtime error πŸ’₯
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

Let me show you how to build this.


Architecture Overview

The core consists of three components:

  1. Discriminated Union Types - Type-safe response handling
  2. Generic Store Class - Works with any data model
  3. Type Guards - Automatic type narrowing

1. Discriminated Union for API Responses

Instead of throwing exceptions, we return a discriminated union:

interface ApiResponseBase<T> {
  data: T | null;
  status: number;
}

interface SuccessResponse<T> extends ApiResponseBase<T> {
  type: 'success';
  message: string;
}

interface ErrorResponse extends ApiResponseBase<null> {
  type: 'error';
  errorDetails: string[];
}

export type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • The type field is the discriminator
  • TypeScript can narrow the union based on type
  • No more try-catch blocks that lose type information

2. Generic Store Implementation

Here's the core getAllItems method:

export class MockAPIStore<T extends { id: string | number }> {
  private data: T[] = [];

  async getAllItems(endpoint: string): Promise<ApiResponse<T[]>> {
    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'
        ],
      };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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<T>(
  response: ApiResponse<T>
): response is SuccessResponse<T> {
  return response.type === 'success';
}

export function isErrorResponse(
  response: ApiResponse
): response is ErrorResponse {
  return response.type === 'error';
}
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

Complete CRUD Implementation

Utility Types for Create/Update

// Omit 'id' for creation (server generates it)
export type CreateItem<T, K extends keyof T> = Omit<T,K>;

// Require 'id', make other fields optional for updates
  export type UpdateItem<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>;
Enter fullscreen mode Exit fullscreen mode

Create Method

async create(
  item: CreateItem<T, "id">,
  endpoint: string
): Promise<ApiResponse<T>> {
  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'
      ],
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Update Method

async updateItem(
  endpoint: string,
  item: UpdateItem<T, "id">
): Promise<ApiResponse<T>> {
  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'
      ],
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Real-World Usage Example

interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
}

const postStore = new MockAPIStore<Post>();

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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!
  });
}
Enter fullscreen mode Exit fullscreen mode

2. Exhaustive Error Handling

// TypeScript forces you to handle both cases
function handleResponse<T>(response: ApiResponse<T>) {
  if (isSuccessResponse(response)) {
    return response.data;
  } else if (isErrorResponse(response)) {
    return [];
  }
  // If you forget a case, TypeScript complains!
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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<User>();
const productStore = new MockAPIStore<Product>();

// Both get full type safety automatically
Enter fullscreen mode Exit fullscreen mode

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[]
}
Enter fullscreen mode Exit fullscreen mode

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<{id: number}>();
    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<{id:number}>();
    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);
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

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, ... };
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

With type guards:

if (isSuccessResponse(response)) {
  console.log(response.data); // Clean!
}
Enter fullscreen mode Exit fullscreen mode

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: [https://github.com/hafizabdullah510/type-safe-mock-api]

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)