正在加载,请稍候…

TypeScript Error Handling: Result Types and Never Pattern

Build robust error handling in TypeScript using Result/Either types, discriminated unions, never checks, and structured error hierarchies instead of try/catch everywhere.

TypeScript Error Handling: Result Types and Never Pattern

The Problem with try/catch

// What errors can this throw? Unknown from the signature
async function getUser(id: string): Promise<User> {
  // Might throw: NotFoundError, DatabaseError, ValidationError, NetworkError
  return await userRepo.findById(id);
}

// Callers must guess what to catch
try {
  const user = await getUser(id);
} catch (err) {
  // What type is err? We don't know without reading the implementation
}

Result Type Pattern

type Ok<T> = { readonly ok: true; readonly value: T };
type Err<E> = { readonly ok: false; readonly error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;

// Constructors
const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = <E>(error: E): Err<E> => ({ ok: false, error });

// Usage - errors are explicit in the type signature!
async function findUser(id: string): Promise<Result<User, UserError>> {
  try {
    const user = await db.findById(id);
    if (!user) return err({ type: 'NOT_FOUND', id } as const);
    return ok(user);
  } catch (e) {
    return err({ type: 'DB_ERROR', cause: e } as const);
  }
}

// Callers handle errors explicitly
const result = await findUser('123');
if (!result.ok) {
  switch (result.error.type) {
    case 'NOT_FOUND': return res.status(404).json({ error: 'User not found' });
    case 'DB_ERROR': return res.status(500).json({ error: 'Database error' });
  }
}
const user = result.value; // TypeScript knows this is User

Typed Error Hierarchy

abstract class AppError extends Error {
  abstract readonly code: string;
  abstract readonly statusCode: number;

  constructor(message: string, readonly cause?: unknown) {
    super(message);
    this.name = this.constructor.name;
  }

  toJSON() {
    return {
      error: this.name,
      code: this.code,
      message: this.message,
    };
  }
}

class NotFoundError extends AppError {
  readonly code = 'NOT_FOUND';
  readonly statusCode = 404;

  constructor(resource: string, id: string) {
    super(`${resource} with id '${id}' not found`);
  }
}

class ValidationError extends AppError {
  readonly code = 'VALIDATION_ERROR';
  readonly statusCode = 400;

  constructor(readonly fields: Record<string, string>) {
    super('Validation failed');
  }
}

class UnauthorizedError extends AppError {
  readonly code = 'UNAUTHORIZED';
  readonly statusCode = 401;
  constructor() { super('Authentication required'); }
}

// Error handler middleware
function errorHandler(err: unknown, req: Request, res: Response) {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json(err.toJSON());
  }
  console.error('Unexpected error:', err);
  return res.status(500).json({ error: 'Internal server error' });
}

The Never Pattern (Exhaustiveness Checking)

type PaymentMethod = 'credit_card' | 'paypal' | 'crypto';

function processPayment(method: PaymentMethod): void {
  switch (method) {
    case 'credit_card':
      processCreditCard();
      break;
    case 'paypal':
      processPaypal();
      break;
    case 'crypto':
      processCrypto();
      break;
    default:
      // TypeScript ensures this is unreachable
      const _exhaustive: never = method;
      throw new Error(`Unhandled payment method: ${_exhaustive}`);
  }
}

// Now if you add 'bank_transfer' to PaymentMethod,
// TypeScript will error at the never assignment,
// forcing you to handle the new case

Chaining with andThen

class Result<T, E> {
  private constructor(
    private readonly _ok: boolean,
    private readonly _value?: T,
    private readonly _error?: E
  ) {}

  static ok<T, E>(value: T): Result<T, E> { return new Result(true, value); }
  static err<T, E>(error: E): Result<T, E> { return new Result(false, undefined, error); }

  get ok(): boolean { return this._ok; }
  get value(): T { if (!this._ok) throw new Error('Result is Err'); return this._value!; }
  get error(): E { if (this._ok) throw new Error('Result is Ok'); return this._error!; }

  map<U>(fn: (value: T) => U): Result<U, E> {
    if (this._ok) return Result.ok(fn(this._value!));
    return Result.err(this._error!);
  }

  andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
    if (this._ok) return fn(this._value!);
    return Result.err(this._error!);
  }
}

// Chain operations
const result = await findUser(userId)
  .andThen(user => validateUserAge(user))
  .andThen(user => checkSubscription(user))
  .map(user => UserDto.from(user));

Making errors part of the type system catches error handling issues at compile time.