TypeScript Error Handling: Result Types, neverthrow, and Typed Error Boundaries
Build robust TypeScript error handling with Result types, neverthrow library, discriminated union errors, typed React error boundaries, and structured error propagation patterns for production apps.
Unhandled promise rejections and uncaught exceptions are silent killers in TypeScript codebases. The type system is sophisticated enough to track what can fail and how โ but only if you design for it. This post covers production-grade error handling patterns: Result types, the neverthrow library, discriminated union errors, typed React error boundaries, and structured error propagation for APIs.
The goal is making the unhappy path explicit in your types so the compiler helps you handle every failure mode.
Why throw Alone Falls Short
// The problem: callers don't know what can fail
async function getUser(id: string): Promise<User> {
const user = await db.user.findUnique({ where: { id } });
if (!user) throw new Error('User not found'); // NotFoundError
if (!user.isActive) throw new Error('Inactive'); // ForbiddenError
return user;
}
// Caller has no idea what to catch
try {
const user = await getUser('123');
} catch (err) {
// Is this a NotFound? Forbidden? Network error? Who knows.
console.error(err);
}
The throw statement erases type information. Every catch (err) block receives unknown in strict TypeScript โ which is correct, but means you must type-narrow every time, or swallow errors carelessly.
1. Discriminated Union Error Types
Define your errors as a closed set of types. This makes exhaustive handling possible.
// src/lib/errors.ts
export interface NotFoundError {
readonly kind: 'NotFound';
readonly resource: string;
readonly id: string;
}
export interface ForbiddenError {
readonly kind: 'Forbidden';
readonly action: string;
readonly resource: string;
}
export interface ValidationError {
readonly kind: 'Validation';
readonly fields: Record<string, string[]>;
}
export interface ConflictError {
readonly kind: 'Conflict';
readonly resource: string;
readonly field: string;
readonly value: unknown;
}
export interface ExternalServiceError {
readonly kind: 'ExternalService';
readonly service: string;
readonly originalError: string;
readonly retryable: boolean;
}
export interface InternalError {
readonly kind: 'Internal';
readonly message: string;
readonly cause?: unknown;
}
// Union of all domain errors
export type DomainError =
| NotFoundError
| ForbiddenError
| ValidationError
| ConflictError
| ExternalServiceError
| InternalError;
// Factory helpers
export const Errors = {
notFound: (resource: string, id: string): NotFoundError => ({
kind: 'NotFound',
resource,
id,
}),
forbidden: (action: string, resource: string): ForbiddenError => ({
kind: 'Forbidden',
action,
resource,
}),
validation: (fields: Record<string, string[]>): ValidationError => ({
kind: 'Validation',
fields,
}),
conflict: (resource: string, field: string, value: unknown): ConflictError => ({
kind: 'Conflict',
resource,
field,
value,
}),
externalService: (service: string, err: unknown, retryable = false): ExternalServiceError => ({
kind: 'ExternalService',
service,
originalError: err instanceof Error ? err.message : String(err),
retryable,
}),
internal: (message: string, cause?: unknown): InternalError => ({
kind: 'Internal',
message,
cause,
}),
} as const;
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
2. Result Type (Manual Implementation)
// src/lib/result.ts
export type Result<T, E = DomainError> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
export const Result = {
ok<T>(value: T): Result<T, never> {
return { ok: true, value };
},
err<E>(error: E): Result<never, E> {
return { ok: false, error };
},
// Map over success value
map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
if (!result.ok) return result;
return Result.ok(fn(result.value));
},
// Chain Results (flatMap / bind)
andThen<T, U, E, F>(
result: Result<T, E>,
fn: (value: T) => Result<U, F>
): Result<U, E | F> {
if (!result.ok) return result;
return fn(result.value);
},
// Transform error
mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
if (result.ok) return result;
return Result.err(fn(result.error));
},
// Unwrap or throw
unwrapOr<T, E>(result: Result<T, E>, fallback: T): T {
return result.ok ? result.value : fallback;
},
// Collect multiple Results โ fails fast on first error
all<T, E>(results: Result<T, E>[]): Result<T[], E> {
const values: T[] = [];
for (const r of results) {
if (!r.ok) return r;
values.push(r.value);
}
return Result.ok(values);
},
} as const;
Using Result in Service Functions
// src/services/user.service.ts
import { db } from '../lib/db';
import { Result, Errors, DomainError } from '../lib';
export async function getUserById(
id: string,
requesterId: string
): Promise<Result<User, DomainError>> {
let user: User | null;
try {
user = await db.user.findUnique({
where: { id },
include: { profile: true },
});
} catch (err) {
return Result.err(Errors.internal('Database query failed', err));
}
if (!user) {
return Result.err(Errors.notFound('User', id));
}
if (!user.isActive) {
return Result.err(Errors.forbidden('view', 'User'));
}
// Visibility check: non-admins can only see their own profile
if (id !== requesterId && !(await isAdmin(requesterId))) {
return Result.err(Errors.forbidden('view', 'User'));
}
return Result.ok(user);
}
export async function updateUserEmail(
userId: string,
newEmail: string
): Promise<Result<User, DomainError>> {
// Check for duplicate email
const existing = await db.user.findUnique({ where: { email: newEmail } });
if (existing && existing.id !== userId) {
return Result.err(Errors.conflict('User', 'email', newEmail));
}
// Validate format (defer to Zod in real code, but shows the pattern)
if (!newEmail.includes('@')) {
return Result.err(Errors.validation({ email: ['Invalid email format'] }));
}
try {
const updated = await db.user.update({
where: { id: userId },
data: { email: newEmail },
});
return Result.ok(updated);
} catch (err: any) {
if (err.code === 'P2025') return Result.err(Errors.notFound('User', userId));
return Result.err(Errors.internal('Update failed', err));
}
}
3. neverthrow for Production Use
The neverthrow library provides a battle-tested ResultAsync type with better ergonomics for async code.
pnpm add neverthrow
// src/services/billing.service.ts
import { ok, err, okAsync, errAsync, ResultAsync, Result } from 'neverthrow';
import { stripe } from '../lib/stripe';
import { db } from '../lib/db';
import { Errors, DomainError } from '../lib/errors';
export function getSubscription(
accountId: string
): ResultAsync<Subscription, DomainError> {
return ResultAsync.fromPromise(
db.subscription.findUniqueOrThrow({ where: { accountId } }),
(e) => Errors.notFound('Subscription', accountId)
);
}
export function createStripeCheckoutSession(
customerId: string,
priceId: string
): ResultAsync<string, DomainError> {
return ResultAsync.fromPromise(
stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/billing`,
}),
(e) => Errors.externalService('Stripe', e, true)
).map((session) => session.url!);
}
// Chain Results with andThen
export async function startTrial(
accountId: string,
planId: string
): Promise<Result<{ checkoutUrl: string }, DomainError>> {
const result = await getSubscription(accountId)
.andThen((sub) => {
if (sub.status !== 'trialing' && sub.status !== 'active') {
return errAsync(Errors.forbidden('start trial', 'Subscription'));
}
return okAsync(sub.stripeCustomerId);
})
.andThen((customerId) => createStripeCheckoutSession(customerId, planId))
.map((url) => ({ checkoutUrl: url }));
return result;
}
Matching on Result
// src/app/api/billing/trial/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { startTrial } from '../../../services/billing.service';
export async function POST(req: NextRequest) {
const { accountId, planId } = await req.json() as { accountId: string; planId: string };
const result = await startTrial(accountId, planId);
// Exhaustive match using neverthrow's match method
return result.match(
(data) => NextResponse.json(data),
(error) => {
switch (error.kind) {
case 'NotFound':
return NextResponse.json(
{ error: `${error.resource} not found`, id: error.id },
{ status: 404 }
);
case 'Forbidden':
return NextResponse.json(
{ error: `Not allowed to ${error.action} ${error.resource}` },
{ status: 403 }
);
case 'ExternalService':
return NextResponse.json(
{ error: 'Payment service unavailable', retryable: error.retryable },
{ status: 502 }
);
case 'Validation':
return NextResponse.json({ error: 'Validation failed', fields: error.fields }, { status: 422 });
case 'Conflict':
return NextResponse.json({ error: 'Resource conflict', field: error.field }, { status: 409 });
case 'Internal':
console.error('Internal error:', error.message, error.cause);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
);
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
4. Typed React Error Boundaries
React error boundaries must be class components. TypeScript can type the fallback and propagation.
// src/components/error/ErrorBoundary.tsx
'use client';
import React from 'react';
export interface ErrorInfo {
componentStack: string;
digest?: string; // Next.js: error hash for server-side deduplication
}
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback: (error: Error, reset: () => void) => React.ReactNode;
onError?: (error: Error, info: ErrorInfo) => void;
}
interface ErrorBoundaryState {
error: Error | null;
}
export class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
this.props.onError?.(error, {
componentStack: info.componentStack ?? '',
digest: (info as any).digest,
});
}
render() {
if (this.state.error) {
return this.props.fallback(this.state.error, () =>
this.setState({ error: null })
);
}
return this.props.children;
}
}
// Typed error categories for React boundary
export type UIErrorKind = 'network' | 'auth' | 'not-found' | 'unknown';
export function classifyError(error: Error): UIErrorKind {
if (error.message.includes('fetch') || error.message.includes('network')) return 'network';
if (error.message.includes('401') || error.message.includes('Unauthorized')) return 'auth';
if (error.message.includes('404') || error.message.includes('Not Found')) return 'not-found';
return 'unknown';
}
// Ready-to-use boundary with typed fallback
export function DashboardErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundary
onError={(error, info) => {
// Send to error tracking (Sentry, etc.)
console.error('Dashboard error:', error, info.componentStack);
}}
fallback={(error, reset) => {
const kind = classifyError(error);
return (
<div role="alert" className="p-6 rounded-lg bg-red-50 border border-red-200">
{kind === 'network' && (
<>
<h2 className="text-lg font-semibold text-red-800">Connection Problem</h2>
<p className="mt-2 text-red-600">Check your internet connection and try again.</p>
</>
)}
{kind === 'auth' && (
<>
<h2 className="text-lg font-semibold text-red-800">Session Expired</h2>
<p className="mt-2 text-red-600">Please log in again to continue.</p>
<a href="/login" className="mt-3 inline-block text-blue-600 underline">
Go to login
</a>
</>
)}
{(kind === 'not-found' || kind === 'unknown') && (
<>
<h2 className="text-lg font-semibold text-red-800">Something went wrong</h2>
<p className="mt-2 text-red-600 text-sm">{error.message}</p>
<button
onClick={reset}
className="mt-3 px-4 py-2 bg-red-800 text-white rounded hover:bg-red-700"
>
Try again
</button>
</>
)}
</div>
);
}}
>
{children}
</ErrorBoundary>
);
}
Next.js App Router: error.tsx
// src/app/dashboard/error.tsx
'use client'; // Error components must be Client Components
import { useEffect } from 'react';
interface ErrorPageProps {
error: Error & { digest?: string }; // digest = Next.js error hash
reset: () => void;
}
export default function DashboardError({ error, reset }: ErrorPageProps) {
useEffect(() => {
// Log to error tracking
console.error('Dashboard route error:', error.digest, error.message);
}, [error]);
return (
<main className="flex min-h-[400px] items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-semibold text-gray-900">Dashboard unavailable</h2>
<p className="mt-2 text-gray-500">{error.message}</p>
{error.digest && (
<p className="mt-1 text-xs text-gray-400">Error ID: {error.digest}</p>
)}
<button
onClick={reset}
className="mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retry
</button>
</div>
</main>
);
}
5. Global Error Handler for Express / Fastify
// src/lib/http/error-handler.ts
import { FastifyInstance, FastifyError } from 'fastify';
import { DomainError } from '../errors';
function domainErrorToHttp(error: DomainError): { statusCode: number; body: object } {
switch (error.kind) {
case 'NotFound':
return {
statusCode: 404,
body: { error: 'Not found', resource: error.resource, id: error.id },
};
case 'Forbidden':
return {
statusCode: 403,
body: { error: 'Forbidden', action: error.action, resource: error.resource },
};
case 'Validation':
return {
statusCode: 422,
body: { error: 'Validation failed', fields: error.fields },
};
case 'Conflict':
return {
statusCode: 409,
body: { error: 'Conflict', field: error.field },
};
case 'ExternalService':
return {
statusCode: error.retryable ? 503 : 502,
body: {
error: 'Upstream service error',
service: error.service,
retryAfter: error.retryable ? 30 : undefined,
},
};
case 'Internal':
return {
statusCode: 500,
body: { error: 'Internal server error' },
};
}
}
// Custom error class that wraps DomainError for Fastify
export class DomainHttpError extends Error {
constructor(public readonly domainError: DomainError) {
super(domainError.kind);
this.name = 'DomainHttpError';
}
}
export function registerErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error, request, reply) => {
// Handle our typed domain errors
if (error instanceof DomainHttpError) {
const { statusCode, body } = domainErrorToHttp(error.domainError);
return reply.status(statusCode).send(body);
}
// Handle Fastify validation errors
if ((error as FastifyError).validation) {
return reply.status(422).send({
error: 'Validation failed',
details: (error as FastifyError).validation,
});
}
// Unknown errors
request.log.error({ err: error }, 'Unhandled error');
return reply.status(500).send({ error: 'Internal server error' });
});
}
// Async route helper that converts Result โ HTTP response
export function resultRoute<T>(
handler: (req: unknown) => Promise<Result<T, DomainError>>
) {
return async (request: unknown, reply: unknown): Promise<void> => {
const result = await handler(request);
if (!result.ok) {
throw new DomainHttpError(result.error);
}
(reply as any).send(result.value);
};
}
Testing Error Handling
// src/services/user.service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { getUserById } from './user.service';
import { db } from '../lib/db';
vi.mock('../lib/db', () => ({
db: { user: { findUnique: vi.fn() } },
}));
describe('getUserById', () => {
it('returns NotFound error when user does not exist', async () => {
vi.mocked(db.user.findUnique).mockResolvedValue(null);
const result = await getUserById('missing-id', 'requester-id');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.kind).toBe('NotFound');
expect(result.error.id).toBe('missing-id');
}
});
it('returns Forbidden error for inactive user', async () => {
vi.mocked(db.user.findUnique).mockResolvedValue({ id: '1', isActive: false } as any);
const result = await getUserById('1', 'requester-1');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.kind).toBe('Forbidden');
}
});
it('returns Internal error on database failure', async () => {
vi.mocked(db.user.findUnique).mockRejectedValue(new Error('Connection refused'));
const result = await getUserById('1', '1');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.kind).toBe('Internal');
}
});
});
Quick Reference: When to Use Each Pattern
| Pattern | When to Use | When Not to Use |
|---|---|---|
Result<T, E> manual | Domain logic, service layer | Simple utilities |
neverthrow | Async chains with multiple failure modes | Synchronous code (just use Result) |
throw | Programming errors (bugs, invalid state) | Expected business errors |
| Error boundary | React render errors | API call errors (use try/catch) |
| Discriminated union errors | Domain error set is closed and known | External API error pass-through |
| HTTP error handler | API surface layer | Deep in business logic |
See Also
- TypeScript Advanced Patterns: Conditional Types, Template Literals, and Mapped Types
- TypeScript Generics Advanced: Variance, Constraints, and Inference
- TypeScript Testing Patterns: Mocks, Stubs, and Type-Safe Tests
- Next.js Testing Strategy: Unit, Integration, and E2E with Playwright
- API Security Best Practices: Authentication, Authorization, and Input Validation
Working With Viprasol
Building a TypeScript application and finding that your error paths are a tangle of uncaught exceptions and any types? We design error handling architectures that make failure modes explicit at compile time โ using Result types, discriminated unions, and typed error boundaries that give your team confidence in the unhappy path.
Talk to our team โ | Explore our web development services โ
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.