Errors#

Error handling in TypeScript is JavaScript’s throw / try / catch (see Errors) with a type layer on top. The catch binding is unknown by default in modern tsconfig; the operator narrows it before use, or returns a Result<T, E> structure to encode failure in the return type instead of throwing.

Catch is unknown#

With useUnknownInCatchVariables (on by default under strict) the catch parameter has type unknown. The operator narrows before reading any properties.

try {
  await fetchUser(id);
} catch (e) {
  if (e instanceof Error) console.error(e.message);
  else                    console.error("unknown:", e);
}

The older default was any, which let e.message go through unchecked and bit operators when the thrown value was a string or a plain object.

Typed Error subclasses#

Subclass Error and set name; the operator carries extra properties on the subclass for the catcher to read.

class HTTPError extends Error {
  constructor(
    public readonly status: number,
    public readonly body: unknown,
  ) {
    super(`HTTP ${status}`);
    this.name = "HTTPError";
  }
}

try { await call(); }
catch (e) {
  if (e instanceof HTTPError && e.status === 401) refresh();
  else throw e;
}

The class machinery is JavaScript; the type layer is in the readonly parameter properties.

Result types#

The operator’s alternative to throwing is to return a Result<T, E> discriminated union. The compiler forces the caller to handle both branches.

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

async function parseConfig(raw: string): Promise<Result<Config, string>> {
  try {
    const data = JSON.parse(raw);
    return {ok: true, value: data};
  } catch (e) {
    return {ok: false, error: `bad JSON: ${(e as Error).message}`};
  }
}

const r = await parseConfig(raw);
if (r.ok) handle(r.value);
else      log.warn(r.error);

For more ceremony (combinators, async pipelines), libraries like neverthrow and effect provide Result / Effect structures with chaining APIs.

zod for input validation#

Do not trust JSON. zod parses and validates in one step; failures are a typed ZodError.

import {z} from "zod";

const ConfigSchema = z.object({
  host: z.string(),
  port: z.number().int().positive(),
});
type Config = z.infer<typeof ConfigSchema>;

const result = ConfigSchema.safeParse(JSON.parse(raw));
if (!result.success) {
  console.error("invalid config:", result.error.flatten());
  return;
}
const config: Config = result.data;

safeParse returns a discriminated union; parse throws. Prefer safeParse for user-facing input and parse for internal trust boundaries.

Exhaustive catch with never#

When you want to prove every error variant is handled, narrow with a discriminated union and a never fallback.

type AppError =
  | {kind: "Network"; cause: Error}
  | {kind: "NotFound"; id: string}
  | {kind: "Forbidden"};

function describe(e: AppError): string {
  switch (e.kind) {
    case "Network":   return `net: ${e.cause.message}`;
    case "NotFound":  return `not found: ${e.id}`;
    case "Forbidden": return "forbidden";
    default: {
      const _exhaustive: never = e;
      throw new Error(`unhandled: ${JSON.stringify(_exhaustive)}`);
    }
  }
}

Async errors#

A rejected promise that nothing handles becomes an unhandledRejection; in Node this aborts the process by default. Always handle at the top of an async call chain.

try {
  const r = await fetch(url);
  if (!r.ok) throw new HTTPError(r.status, await r.text());
  return await r.json();
} catch (e) {
  log.error(e);
  throw e;
}

Promise.all rejects on the first failure; Promise.allSettled returns {status, value} / {status, reason} per input. AggregateError (Promise.any) carries multiple errors.

const results = await Promise.allSettled(urls.map(fetch));
const failures = results.flatMap(r => r.status === "rejected" ? [r.reason] : []);

Wrapping with cause#

Modern Error accepts {cause} to carry the original without losing the stack.

try { await load(id); }
catch (e) {
  throw new Error(`load ${id} failed`, {cause: e});
}

Typed wrappers preserve narrow types on the cause.

class ServiceError<E extends Error> extends Error {
  constructor(msg: string, public readonly cause: E) {
    super(msg, {cause});
    this.name = "ServiceError";
  }
}

Assertions#

assert returns asserts cond so the compiler narrows after the call.

function assert(cond: unknown, msg = "assertion failed"): asserts cond {
  if (!cond) throw new Error(msg);
}

function process(req: Request | null) {
  assert(req, "req must be present");
  req.headers;            // req: Request from here on
}

References#

  • Errors for the underlying throw / try machinery.

  • Types for Result structures, discriminated unions, and never.

  • Functions for asserts signatures.

  • zod, runtime validation that yields TypeScript types.

  • neverthrow, Result and ResultAsync for async chains.