TypeScript Result pattern implementation using discriminated unions allows you to handle errors explicitly, eliminating hidden runtime exceptions in your code.
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.
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.
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.
TYPESCRIPTtype 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.
Let’s look at a concrete example. Imagine a function that fetches a user profile.
TYPESCRIPTfunction 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.
TYPESCRIPTconst 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);
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.
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.
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.
TypeScript Dependency Injection doesn't require heavy decorators. Learn how to use constructor overloading for a type-safe architecture that simplifies testing.