Back to Blog
TypeScriptJavaScriptJune 22, 20264 min read

TypeScript Result Pattern: Replacing Exceptions with Discriminated Unions

TypeScript Result pattern implementation using discriminated unions allows you to handle errors explicitly, eliminating hidden runtime exceptions in your code.

TypeScriptError HandlingFunctional ProgrammingSoftware ArchitectureBest PracticesJavaScriptFrontend

During an on-call rotation last year, I spent three hours debugging a silent failure in a data processing pipeline. A deeply nested service was throwing a custom error that wasn't caught by the top-level handler, leaving our state in an inconsistent mess. I realized then that exception-driven control flow is a liability in complex systems. We needed a way to make failure a first-class citizen.

The solution was to stop relying on throw and start returning an explicit Result object. By leveraging TypeScript and discriminated unions, I was able to turn unpredictable runtime crashes into compile-time requirements.

Why Exceptions Fail You

In standard JavaScript, functions are "liars." A function signature like getUser(id: string): User implies it will return a user. But in reality, it might throw a DatabaseConnectionError, a UserNotFoundError, or a ValidationError.

Because these exceptions aren't part of the type signature, you often forget to catch them. Even if you use try/catch, you’re forced into a specific control flow that separates error handling from your business logic. It’s messy, and it’s hard to test.

Implementing the Result Pattern

To fix this, we define two types: Success and Failure. By using a shared "tag" property—the discriminator—TypeScript can narrow down exactly which state we're dealing with.

TYPESCRIPT
type Success<T> = {
  kind: CE9178">'success';
  data: T;
};

type Failure<E> = {
  kind: CE9178">'failure';
  error: E;
};

type Result<T, E> = Success<T> | Failure<E>;

This pattern is the foundation of functional error handling. Instead of throwing, your functions now explicitly declare that they might return an error.

Putting Discriminated Unions to Work

Let’s look at a concrete example. Imagine a function that fetches a user profile.

TYPESCRIPT
function fetchUser(id: string): Result<User, CE9178">'NOT_FOUND' | CE9178">'DB_ERROR'> {
  // Imagine some logic here
  if (dbDown) return { kind: CE9178">'failure', error: CE9178">'DB_ERROR' };
  if (!user) return { kind: CE9178">'failure', error: CE9178">'NOT_FOUND' };
  
  return { kind: CE9178">'success', data: user };
}

Now, when you call this function, you can't access data without checking the kind. If you try, TypeScript will throw a compiler error. This is a massive upgrade over try/catch, where you might accidentally access undefined or a partial object.

TYPESCRIPT
const result = fetchUser(CE9178">'123');

if (result.kind === CE9178">'failure') {
  console.error(result.error); // TypeScript knows exactly what this is
  return;
}

// Here, result is narrowed to Success<User>
console.log(result.data.name);

Moving Beyond Simple Returns

While this works for simple functions, it gets even better when you integrate it with other patterns. If you're building complex data pipelines, you might find that TypeScript Branded Types: Enforcing Domain Integrity at Compile-Time helps keep your Success payloads clean and validated.

However, the real power of the Result pattern comes when you start chaining operations. Writing manual if/else checks for every step becomes tedious. I’ve experimented with "pipe" functions that handle the unwrapping for you, but be careful—over-abstracting can make your code harder to read for junior teammates.

Trade-offs and Lessons Learned

I initially tried to implement a full-blown "Monad" library to handle these results. It was a mistake. My team struggled to understand the map and flatMap logic, and our onboarding time increased by about two days. We reverted to simple, explicit if/else checks using discriminated unions and it was much more maintainable.

If you're interested in applying these safety patterns to your infrastructure, check out how Next.js Server Actions: Implementing Type-Safe Mutations and Middleware uses similar validation strategies to keep mutations predictable.

Frequently Asked Questions

Does this replace all try/catch blocks? Not necessarily. Use the Result pattern for expected domain errors (e.g., "User not found," "Validation failed"). Keep try/catch for truly exceptional, unrecoverable system failures like network timeouts or out-of-memory errors.

Is this just functional programming? It's inspired by functional programming, but it's just standard TypeScript. You don't need to learn category theory to use it; you just need to understand how to narrow union types.

Does it add overhead? The runtime overhead is negligible. You're just returning an object instead of throwing an exception, which is often faster in V8 anyway. The real cost is in code verbosity, which is a fair trade for the reliability you gain.

I’m still refining my approach to handling nested results. Sometimes, the "pyramid of doom" returns, and I’m tempted to reach for a library again. But for now, keeping it explicit has kept my production code significantly more stable than it was a year ago.

Similar Posts