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;
}
onst 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 {
  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;
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 {
  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'
        ],
      };
    }
  }
}
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(
  response: ApiResponse
): response is SuccessResponse {
  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 = Omit;

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

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

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

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);
  }
}
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(response: ApiResponse) {
  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();
const productStore = new MockAPIStore();

// 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();
    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);
    }
  });
});
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: [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)