All posts

TypeScript Patterns for Safer Boundaries Between Front-End and Back-End

Learn how branded types, discriminated unions, and exhaustive narrowing prevent runtime bugs and create durable contracts across your full-stack TypeScript codebase.

3 min read

When we use TypeScript across both the front-end and back-end, it is tempting to assume that sharing interfaces is enough to keep an application safe. In practice, primitive types and loose interfaces leave gaps where invalid data leaks between layers. Over the last few years working across client frameworks and server runtimes, I have found three specific typing patterns that dramatically reduce defects at runtime: branded types, discriminated union results, and exhaustive pattern matching.

Preventing ID Confusion with Branded Types

Most backend systems pass multiple identifiers: user IDs, organization IDs, and order IDs. In standard TypeScript, every ID is typically represented as a string or a number. This opens the door to passing an account ID into a function expecting a user ID without any compiler errors.

We can solve this using branded types (sometimes called nominal typing). TypeScript does not natively support nominal types, but we can simulate them by attaching a unique, uninstantiable property tag:

declare const __brand: unique symbol;

type Brand<T, B> = T & { readonly [__brand]: B };

export type UserId = Brand<string, 'UserId'>;
export type OrgId = Brand<string, 'OrgId'>;

export function toUserId(raw: string): UserId {
  if (!raw.startsWith('usr_')) {
    throw new Error('Invalid user ID format');
  }
  return raw as UserId;
}

export function toOrgId(raw: string): OrgId {
  if (!raw.startsWith('org_')) {
    throw new Error('Invalid organization ID format');
  }
  return raw as OrgId;
}

Now consider a service method on the server or an API client helper in the front-end:

async function fetchMembership(userId: UserId, orgId: OrgId) {
  // Implementation here
}

const user = toUserId('usr_123');
const org = toOrgId('org_999');

// This compiles cleanly
await fetchMembership(user, org);

// Argument of type 'OrgId' is not assignable to parameter of type 'UserId'.
// await fetchMembership(org, user);

The compiler enforces that data has gone through an explicit validation or parsing boundary before it reaches critical domain logic.

Explicit Result Types Instead of Thrown Exceptions

On both Node servers and front-end HTTP callers, unchecked exceptions make control flow unpredictable. When a network request fails or a database query rejects, an unhandled error can crash a server handler or leave a UI stuck in a loading state.

Instead of throwing errors for expected operational failures, I model operations with discriminated union result types:

export type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

export function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

export function fail<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

We can use this to represent API responses safely:

type ApiError =
  | { code: 'NOT_FOUND'; message: string }
  | { code: 'UNAUTHORIZED'; message: string };

async function loadUserProfile(id: UserId): Promise<Result<UserProfile, ApiError>> {
  const res = await fetch(`/api/users/${id}`);
  
  if (res.status === 404) {
    return fail({ code: 'NOT_FOUND', message: 'User does not exist' });
  }
  if (res.status === 401) {
    return fail({ code: 'UNAUTHORIZED', message: 'Authentication required' });
  }
  
  const data = await res.json();
  return ok(data);
}

The calling code cannot access result.value without checking result.ok first. This forces every consumer—whether a client-side store or a background task—to explicitly handle failure modes.

Exhaustiveness Checking with the never Type

Once you use discriminated unions for state and errors, you need a guarantee that adding a new variant does not silently break consumers. TypeScript’s never type lets you construct compile-time exhaustiveness guards:

export function assertUnreachable(x: never): never {
  throw new Error(`Unhandled union variant: ${JSON.stringify(x)}`);
}

When handling states in a front-end view or routing errors in an API handler, use the helper in the default branch of a switch:

function renderError(error: ApiError): string {
  switch (error.code) {
    case 'NOT_FOUND':
      return 'We could not find the requested record.';
    case 'UNAUTHORIZED':
      return 'Please log in to continue.';
    default:
      return assertUnreachable(error);
  }
}

If you later introduce { code: 'RATE_LIMITED'; message: string } to ApiError, the compiler immediately highlights renderError because error in the default block is no longer assignable to never. Your build fails before buggy code reaches production.

Writing Boundaries, Not Just Types

TypeScript is most effective when it models constraints, not just shapes. By combining branded primitives, explicit Result types, and exhaustive narrowing, you stop bugs at the boundary between your front-end components and back-end endpoints. The system requires minimal boilerplate, eliminates hidden runtime surprises, and allows your codebase to evolve safely.

Have a project in mind?

I build and run web apps end to end.

Get in touch