Back to Blog

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.

Viprasol Tech Team
November 2, 2026
13 min read

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

PatternWhen to UseWhen Not to Use
Result<T, E> manualDomain logic, service layerSimple utilities
neverthrowAsync chains with multiple failure modesSynchronous code (just use Result)
throwProgramming errors (bugs, invalid state)Expected business errors
Error boundaryReact render errorsAPI call errors (use try/catch)
Discriminated union errorsDomain error set is closed and knownExternal API error pass-through
HTTP error handlerAPI surface layerDeep in business logic

See Also


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 โ†’

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol ยท Web Development

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.