TypeScript Typed Errors and Custom Error Classes

TypeScript’s type system does not extend into catch clauses by default — the caught value arrives as any, bypassing every safety guarantee the compiler provides. This guide covers the full path from activating useUnknownInCatchVariables, through building correctly typed Error subclasses, to modelling fallible operations as discriminated-union result types. It belongs to the Core JavaScript Error Handling & Boundaries reference and pairs with Narrowing unknown in TypeScript Catch Clauses and Preserving Stack Traces in Custom Error Subclasses for implementation detail.

Concrete outcomes after applying this guide:

  • The compiler rejects any unsafe access on caught values without an explicit type narrowing step.
  • Custom Error subclasses carry typed properties and maintain correct instanceof chains in all transpilation targets.
  • Discriminated-union result types make the error path structurally explicit at every call site.
  • Observability pipelines receive properly shaped payloads without runtime serialization surprises.
TypeScript typed error flow Flow diagram showing a thrown value entering a catch clause as unknown, passing through an isError type guard and instanceof narrowing step, then branching to a typed NetworkError handler and a fallback unknown handler, both feeding into a telemetry sink. throw new NetworkError() catch (e) e: unknown (useUnknownIn…) instanceof / isError() guard narrows type NetworkError handler unknown fallback telemetry.capture() typed payload

Problem Framing & Symptom Identification

TypeScript before version 4.0 typed the catch binding as any. That means code like e.statusCode or e.response.data compiled without errors even when e was a plain string, null, or a custom object with a different shape. Accessing .message on a non-Error value silently returns undefined at runtime, producing log lines like "Error: undefined" that yield no diagnostic information.

The two most common symptoms are:

  1. Observability dashboards show "Error: undefined" or "Error: [object Object]" — a caught value that was never an Error instance was passed directly to telemetry.capture(err.message).
  2. instanceof checks pass but properties are absent — a custom subclass compiled to ES5 or CommonJS breaks the prototype chain so instanceof NetworkError returns false even though the object was constructed with new NetworkError().

Both problems have structural TypeScript solutions. The first is prevented by useUnknownInCatchVariables. The second requires Object.setPrototypeOf in the subclass constructor.

Prerequisites & Environment Setup

# Minimum TypeScript version for useUnknownInCatchVariables
npm install typescript@>=4.4.0 --save-dev

# tsconfig.json: strict mode enables useUnknownInCatchVariables automatically
# or enable it in isolation:
{
  "compilerOptions": {
    "target": "ES2017",
    "useUnknownInCatchVariables": true,
    "strict": true,
    "lib": ["ES2017", "DOM"]
  }
}

Enabling strict: true is sufficient — it implies useUnknownInCatchVariables. If you need to adopt incrementally without the full strict suite, set useUnknownInCatchVariables: true in isolation. The target must be ES2015 or higher for native class semantics; targeting ES5 requires the Object.setPrototypeOf workaround in every Error subclass.

Step-by-Step Implementation

Step 1: Enable useUnknownInCatchVariables

Add the flag to tsconfig.json (or rely on strict: true). Once enabled, every catch (e) binding has type unknown. Any property access on e without a type narrowing step produces a compile error:

try {
  await fetchUser(id);
} catch (e) {
  // TS2571: Object is of type 'unknown'
  console.error(e.message); // compile error — forces you to narrow first
}

This is intentional. The compiler is telling you that e could be anything: a string, a number, null, or a custom object thrown from third-party code.

Step 2: Write a Reusable isError Type Guard

The simplest narrowing helper checks the instanceof Error relationship and asserts the type:

// src/utils/isError.ts

export function isError(value: unknown): value is Error {
  return value instanceof Error;
}

export function isErrorWithMessage(value: unknown): value is { message: string } {
  // Handles plain objects and non-Error throwables from third-party code
  return (
    typeof value === 'object' &&
    value !== null &&
    'message' in value &&
    typeof (value as Record<string, unknown>).message === 'string'
  );
}

export function toError(value: unknown): Error {
  if (isError(value)) return value;
  if (isErrorWithMessage(value)) return new Error(value.message);
  return new Error(String(value)); // last resort: stringify the thrown value
}

The toError utility guarantees a valid Error instance regardless of what was thrown. This is the correct intake point for any global error handler or telemetry pipeline.

Step 3: Define Typed Custom Error Subclasses

Typed subclasses must satisfy two constraints: the TypeScript compiler must accept instanceof narrowing against them, and the prototype chain must be correct at runtime in all transpilation targets.

// src/errors/NetworkError.ts

export class NetworkError extends Error {
  readonly statusCode: number;
  readonly endpoint: string;

  constructor(message: string, statusCode: number, endpoint: string) {
    super(message);
    this.name = 'NetworkError';
    this.statusCode = statusCode;
    this.endpoint = endpoint;

    // Required when transpiling to ES5: restores the prototype chain
    // broken by TypeScript's class-to-prototype-function transform.
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

export class ValidationError extends Error {
  readonly fields: Record<string, string[]>;

  constructor(message: string, fields: Record<string, string[]>) {
    super(message);
    this.name = 'ValidationError';
    this.fields = fields;
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

The Object.setPrototypeOf(this, new.target.prototype) call is the critical line. When TypeScript transpiles classes to ES5, it calls Error.call(this) instead of super(), which does not set this to the newly allocated object. The prototype chain becomes NetworkError.prototypeObject.prototype, skipping Error.prototype. instanceof NetworkError then returns false for any thrown instance. The setPrototypeOf call repairs this before the constructor returns.

Step 4: Apply instanceof Narrowing in catch Clauses

With typed subclasses defined, the compiler can narrow a caught unknown through a chain of instanceof checks:

import { NetworkError, ValidationError } from './errors';
import { toError } from './utils/isError';

async function loadProfile(userId: string): Promise<Profile> {
  try {
    return await api.get(`/profiles/${userId}`);
  } catch (e: unknown) {
    if (e instanceof NetworkError) {
      // TypeScript knows: e.statusCode is number, e.endpoint is string
      telemetry.capture(e, {
        type: 'network',
        statusCode: e.statusCode,
        endpoint: e.endpoint,
      });
      throw e; // re-throw after telemetry
    }
    if (e instanceof ValidationError) {
      // TypeScript knows: e.fields is Record<string, string[]>
      logger.warn('Validation failed', { fields: e.fields });
      throw e;
    }
    // Unknown throwable: coerce to Error and capture
    throw toError(e);
  }
}

The compiler enforces that you handle the unknown case. Removing the final throw toError(e) produces a type error on the return type — the function cannot be guaranteed to return a Profile without covering all code paths.

Step 5: Model Result Types with Discriminated Unions

For functions where errors are expected outcomes rather than exceptional states, a discriminated-union result type makes the error path structurally explicit. The caller cannot access .value without first checking .ok, which is enforced by the type system:

// src/types/result.ts

export type Result<T, E extends Error = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

export function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

export function err<E extends Error>(error: E): Result<never, E> {
  return { ok: false, error };
}
// Usage: caller is forced to check ok before accessing value
async function parseConfig(raw: string): Promise<Result<Config, ValidationError>> {
  try {
    const parsed = JSON.parse(raw);
    const config = validateConfig(parsed); // throws ValidationError on bad schema
    return ok(config);
  } catch (e: unknown) {
    if (e instanceof ValidationError) return err(e);
    throw toError(e); // unexpected errors still propagate
  }
}

// At the call site:
const result = await parseConfig(rawInput);
if (!result.ok) {
  // TypeScript knows result.error is ValidationError here
  logger.warn('Config invalid', { fields: result.error.fields });
  return defaultConfig;
}
// TypeScript knows result.value is Config here
applyConfig(result.value);

Discriminated unions are most appropriate when the error is a foreseeable domain event: validation failures, authorization rejections, resource-not-found responses. Reserve thrown exceptions for genuinely unexpected states where recovery is not planned at the call site.

Production Telemetry Integration

Typed error classes pay off at the telemetry boundary. Observability SDKs accept a structured extras object alongside the thrown instance; typed properties eliminate the guesswork about what to attach:

import * as Sentry from '@sentry/browser';
import { NetworkError, ValidationError } from './errors';
import { toError } from './utils/isError';

function captureTypedError(e: unknown): void {
  if (e instanceof NetworkError) {
    Sentry.captureException(e, {
      tags: { error_type: 'network', status_code: String(e.statusCode) },
      extra: { endpoint: e.endpoint },
    });
    return;
  }
  if (e instanceof ValidationError) {
    Sentry.captureException(e, {
      tags: { error_type: 'validation' },
      extra: { fields: JSON.stringify(e.fields) },
    });
    return;
  }
  // Coerce unknown throwables before passing to SDK
  Sentry.captureException(toError(e), { tags: { error_type: 'unknown' } });
}

This pattern prevents the SDK from receiving raw strings or plain objects, which would appear in dashboards as unactionable "Error: [object Object]" events. The error_type tag enables dashboard filtering and alert routing by error category without writing custom grouping logic.

For stack trace resolution in production, source maps must be uploaded to the observability platform for each release build. Typed class names (NetworkError, ValidationError) surface correctly in symbolicated stack frames only if class names are preserved during minification — configure your bundler to keep class names via keep_classnames: true (Terser) or equivalent.

Custom serialization for typed errors

Observability SDKs serialize Error instances using internal logic that varies by vendor. Some walk enumerable own properties; others call JSON.stringify directly. Native Error properties (message, stack, name) are non-enumerable, so SDKs that rely on enumeration silently drop them. Add an explicit toJSON method to every typed subclass to guarantee a complete, predictable payload regardless of SDK serialization strategy:

export class NetworkError extends Error {
  readonly statusCode: number;
  readonly endpoint: string;

  constructor(message: string, statusCode: number, endpoint: string) {
    super(message);
    this.name = 'NetworkError';
    this.statusCode = statusCode;
    this.endpoint = endpoint;
    Object.setPrototypeOf(this, new.target.prototype);
  }

  toJSON(): Record<string, unknown> {
    return {
      name: this.name,
      message: this.message,
      statusCode: this.statusCode,
      endpoint: this.endpoint,
      stack: this.stack,       // explicitly included — non-enumerable otherwise
    };
  }
}

toJSON is called automatically by JSON.stringify when present. This guarantees the stack trace and all typed fields appear in serialized form. Keep the toJSON output minimal — include only fields required for triage. Attaching large objects such as full request bodies or session state causes payload bloat that triggers 413 Request Entity Too Large from ingestion endpoints.

Error hierarchies and shared base classes

Applications commonly need a taxonomy of typed errors that share cross-cutting properties such as a correlation ID or a severity level. A shared AppError base class encodes these without repeating the Object.setPrototypeOf pattern in every leaf class:

// src/errors/AppError.ts

export type ErrorSeverity = 'low' | 'medium' | 'high' | 'critical';

export abstract class AppError extends Error {
  abstract readonly errorCode: string;
  readonly severity: ErrorSeverity;
  readonly correlationId: string;

  constructor(message: string, severity: ErrorSeverity, correlationId: string) {
    super(message);
    this.severity = severity;
    this.correlationId = correlationId;
    // Runs once here; leaf constructors do not need to repeat it
    Object.setPrototypeOf(this, new.target.prototype);
  }

  toJSON(): Record<string, unknown> {
    return {
      name: this.name,
      errorCode: this.errorCode,
      message: this.message,
      severity: this.severity,
      correlationId: this.correlationId,
      stack: this.stack,
    };
  }
}
// src/errors/NetworkError.ts

import { AppError } from './AppError';

export class NetworkError extends AppError {
  readonly errorCode = 'NET_ERR' as const;
  readonly statusCode: number;
  readonly endpoint: string;

  constructor(
    message: string,
    statusCode: number,
    endpoint: string,
    correlationId: string,
  ) {
    super(message, statusCode >= 500 ? 'high' : 'medium', correlationId);
    this.name = 'NetworkError';
    this.statusCode = statusCode;
    this.endpoint = endpoint;
  }

  override toJSON() {
    return { ...super.toJSON(), statusCode: this.statusCode, endpoint: this.endpoint };
  }
}

The abstract readonly errorCode property forces every concrete subclass to declare a stable, short error code. This code becomes the primary grouping key in observability dashboards: alert rules fire on errorCode: 'NET_ERR' rather than on a string-matched class name that can change across refactors.

The severity field drives alert routing without custom tag logic in the observability SDK — filter on severity: 'critical' to page on-call, severity: 'low' to batch into daily digests.

Exhaustiveness checking with discriminated error unions

When you combine typed result types with a finite set of error subtypes, TypeScript can enforce at compile time that every error case is handled. The assertNever pattern makes any missing branch a compile error:

// src/types/result.ts (extended)

import { NetworkError } from '../errors/NetworkError';
import { ValidationError } from '../errors/ValidationError';
import { AuthError } from '../errors/AuthError';

export type ApiError = NetworkError | ValidationError | AuthError;

export type ApiResult<T> = { ok: true; value: T } | { ok: false; error: ApiError };

function assertNever(value: never): never {
  // This line is unreachable if all branches are handled.
  // If TypeScript reaches it, the error type has grown without updating the switch.
  throw new Error(`Unhandled error type: ${(value as Error).name}`);
}

function handleApiError(error: ApiError): void {
  switch (error.errorCode) {
    case 'NET_ERR':
      // TypeScript narrows error to NetworkError here
      telemetry.capture(error, { statusCode: error.statusCode });
      break;
    case 'VAL_ERR':
      // TypeScript narrows error to ValidationError here
      logger.warn('Validation failed', { fields: error.fields });
      break;
    case 'AUTH_ERR':
      // TypeScript narrows error to AuthError here
      redirectToLogin(error.requiredScope);
      break;
    default:
      assertNever(error); // compile error if a new ApiError subtype is added
  }
}

The errorCode property is declared as a string literal type ('NET_ERR' as const) in each subclass. TypeScript uses these literals to narrow the discriminated union inside switch cases exactly as it would narrow a plain string discriminant. Adding a new ApiError subtype without updating handleApiError produces: Argument of type 'NewError' is not assignable to parameter of type 'never' — a clear compile-time signal that the switch is incomplete.

This pattern is most valuable at API boundary adapters, global error handlers, and telemetry intake functions where the full range of possible errors must be explicitly accounted for.

Verification & Testing

Test that the prototype chain is intact and that type narrowing works at runtime, not just at compile time:

// src/errors/__tests__/NetworkError.test.ts
import { NetworkError } from '../NetworkError';

describe('NetworkError', () => {
  it('passes instanceof checks in ES5 targets', () => {
    const e = new NetworkError('timeout', 504, '/api/data');
    expect(e instanceof NetworkError).toBe(true); // fails without setPrototypeOf
    expect(e instanceof Error).toBe(true);         // must also hold
  });

  it('carries typed properties', () => {
    const e = new NetworkError('not found', 404, '/api/users/99');
    expect(e.statusCode).toBe(404);
    expect(e.endpoint).toBe('/api/users/99');
    expect(e.name).toBe('NetworkError');
  });

  it('includes a stack trace', () => {
    const e = new NetworkError('fail', 500, '/api');
    expect(typeof e.stack).toBe('string');
    expect(e.stack!.length).toBeGreaterThan(0);
  });
});

The first test — instanceof NetworkError — is the regression check for the Object.setPrototypeOf requirement. If you remove the call and compile to ES5, this test fails while the TypeScript compiler remains silent about it.

Failure Modes & Edge Cases

Scenario Root Cause Fix
instanceof NetworkError returns false at runtime Missing Object.setPrototypeOf when compiling to ES5 Add Object.setPrototypeOf(this, new.target.prototype) in every subclass constructor
e.message is undefined in telemetry Caught value was not an Error instance; .message access on unknown silently returned undefined before TS 4.4 Enable useUnknownInCatchVariables; use toError() to normalize the caught value
error.name shows "Error" instead of "NetworkError" this.name assignment omitted in subclass constructor Set this.name = 'NetworkError' explicitly after super()
Stack traces point into toError utility new Error(String(value)) allocates a new Error at the utility call site Accept this as intentional; the trace marks where the unknown value was normalized
Discriminated union ok: false branch not exhaustive Additional error subtypes added to the union but not handled Use TypeScript’s never exhaustiveness pattern with a assertNever(result) call
Class name minified in production symbolication Terser renames class names by default Set keep_classnames: true or use /* @__PURE__ */ annotations on error classes

FAQ

Why does TypeScript not allow typing catch bindings as a specific Error subclass? The ECMAScript specification allows any value to be thrown — strings, numbers, null, plain objects, or custom class instances. TypeScript mirrors this: constraining the binding type to NetworkError would be a false guarantee that the compiler cannot verify. The correct pattern is to type the binding as unknown and use runtime narrowing.

When should I use discriminated-union results instead of thrown exceptions? Use result types for error conditions that callers are expected to handle as part of normal flow: invalid input, resource not found, authorization denied. Use thrown exceptions for programming errors, contract violations, and states where the calling code has no sensible recovery path. Mixing both freely in a codebase creates inconsistent error handling conventions that are hard to audit.

Does Object.setPrototypeOf affect performance? The call is executed once per constructor invocation and its cost is negligible compared to the network operations that typically precede an error. The V8 and SpiderMonkey engines optimize setPrototypeOf on freshly allocated objects. The performance concern that motivated the MDN warning applies to hot-path property access on objects whose prototype is mutated repeatedly, not to constructor-time initialization.

How do I handle errors from third-party libraries that do not extend Error? Use the toError utility shown in Step 2 to coerce any thrown value to an Error instance. If the library throws typed objects with known shapes, write a dedicated narrowing function — isAxiosError(e) is a common example — before reaching for toError. Always add a fallback toError call to handle unexpected shapes from future library versions.