Skip to main content
TypeScript · lesson 14 of 21
TypeScript intermediate Lesson 14 of 21

Error Handling in TypeScript

unknown catch variables, type guards, assertion functions, Result types, exhaustive checks and custom errors with cause — with real tsc and tsx output.

By SkillByExample · Updated

Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.

Caught Errors Are unknown

JavaScript can throw any value, so under strict the catch variable is unknown and has to be narrowed.

const attempts: (() => unknown)[] = [
  () => JSON.parse("{bad json"),
  () => { throw "a plain string"; },
  () => { throw { code: 42 }; },
];

function toError(value: unknown): Error {
  return value instanceof Error ? value : new Error(`non-Error thrown: ${JSON.stringify(value)}`);
}

for (const attempt of attempts) {
  try {
    attempt();
  } catch (err) {
    const error = toError(err);
    console.log(`${error.name}: ${error.message}`);
  }
}
$ npx tsc --noEmit --module nodenext catch.ts
$ npx tsx catch.ts
SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
Error: non-Error thrown: "a plain string"
Error: non-Error thrown: {"code":42}

Only the first one is a real Error (a SyntaxError, subclass included). Code that assumed err.message would have printed undefined twice. Normalising once, at the catch, means everything after it can rely on an Error.

Built-in Type Guards

typeof, instanceof and in all narrow a type inside the branch where the check passed.

interface Cat { meow(): string }
interface Dog { bark(): string }

function describe(value: string | number | Date | Cat | Dog): string {
  if (typeof value === "string") return `string "${value.toUpperCase()}"`;
  if (typeof value === "number") return `number ${value.toFixed(2)}`;
  if (value instanceof Date) return `date ${value.toISOString().slice(0, 10)}`;
  if ("meow" in value) return `cat says ${value.meow()}`;
  return `dog says ${value.bark()}`;
}

console.log(describe("hi"));
console.log(describe(Math.PI));
console.log(describe(new Date(Date.UTC(2026, 8, 30))));
console.log(describe({ meow: () => "meow" }));
console.log(describe({ bark: () => "woof" }));
$ npx tsc --noEmit --module nodenext guards.ts
$ npx tsx guards.ts
string "HI"
number 3.14
date 2026-09-30
cat says meow
dog says woof

After the last if, only Dog is left, so value.bark() needs no check — the compiler has eliminated every other member of the union. in works on interfaces, which have no runtime existence for instanceof to test.

User-Defined Type Guards

When the check is too involved for one expression, a function returning value is T teaches the compiler what it means.

interface User { id: number; name: string }

function isUser(value: unknown): value is User {
  return typeof value === "object" && value !== null
    && "id" in value && typeof value.id === "number"
    && "name" in value && typeof value.name === "string";
}

const inputs: unknown[] = [{ id: 1, name: "Alice" }, { id: "1", name: "Bob" }, null];
for (const input of inputs) {
  if (isUser(input)) {
    console.log("user", input.name.toUpperCase());
  } else {
    console.log("not a user:", JSON.stringify(input));
  }
}

function isUserLying(value: unknown): value is User {
  return true;
}
$ npx tsc --noEmit --module nodenext is-user.ts
$ npx tsx is-user.ts
user ALICE
not a user: {"id":"1","name":"Bob"}
not a user: null

No as any needed: since TypeScript 4.9, "id" in value narrows an object to one with an id property of type unknown, which typeof can then check. The last function compiles too — a type predicate is a promise the compiler can’t verify. If isUserLying were used instead, null would reach input.name and crash.

Assertion Functions

An assertion function throws when a check fails, and narrows the type for the rest of the scope after the call.

function assertDefined<T>(value: T, name: string): asserts value is NonNullable<T> {
  if (value == null) throw new Error(`${name} is required`);
}

function readPort(env: Record<string, string | undefined>): number {
  const raw = env.PORT;
  assertDefined(raw, "PORT");
  const port = Number(raw.trim());
  if (!Number.isInteger(port)) throw new Error(`PORT must be an integer, got "${raw}"`);
  return port;
}

for (const env of [{ PORT: " 8080 " }, {}, { PORT: "http" }]) {
  try {
    console.log(readPort(env));
  } catch (e) {
    console.log((e as Error).message);
  }
}
$ npx tsc --noEmit --module nodenext assert.ts
$ npx tsx assert.ts
8080
PORT is required
PORT must be an integer, got "http"

Before the assertDefined call raw is string | undefined; after it, raw.trim() needs no check. One rule trips people up — an assertion function must be declared with an explicit type, so an arrow function in a const without an annotation doesn’t qualify:

const assertDefined = <T,>(value: T): asserts value is NonNullable<T> => {
  if (value == null) throw new Error("missing");
};

const raw: string | undefined = process.argv[2];
assertDefined(raw);
console.log(raw.length);
$ npx tsc --noEmit --module nodenext --types node assert-arrow.ts
assert-arrow.ts(6,1): error TS2775: Assertions require every name in the call target to be declared with an explicit type annotation.

Write it as a function declaration, or annotate the constant: const assertDefined: <T>(value: T) => asserts value is NonNullable<T> = ....

The Result Type

Exceptions don’t appear in signatures. Returning a Result makes failure part of the type, and callers can’t read the value without checking.

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const ok = <T,>(value: T) => ({ ok: true, value }) as const;
const err = <E,>(error: E) => ({ ok: false, error }) as const;

function parseJson(raw: string): Result<unknown, string> {
  try {
    return ok(JSON.parse(raw));
  } catch {
    return err(`invalid JSON: ${raw}`);
  }
}

function getNumber(data: unknown, key: string): Result<number, string> {
  if (typeof data === "object" && data !== null && key in data) {
    const v = (data as Record<string, unknown>)[key];
    if (typeof v === "number") return ok(v);
  }
  return err(`missing number "${key}"`);
}

function flatMap<T, U, E>(r: Result<T, E>, fn: (v: T) => Result<U, E>): Result<U, E> {
  return r.ok ? fn(r.value) : r;
}

for (const raw of ['{"value": 42}', '{"value": "42"}', "{oops"]) {
  const r = flatMap(parseJson(raw), (data) => getNumber(data, "value"));
  console.log(r.ok ? `value ${r.value * 2}` : `error: ${r.error}`);
}
$ npx tsc --noEmit --module nodenext result.ts
$ npx tsx result.ts
value 84
error: missing number "value"
error: invalid JSON: {oops

flatMap chains steps that can each fail, and the first failure short-circuits the rest — whichever step failed, its error comes out the end. The as const in ok/err makes ok: true a literal type, which is what lets r.ok discriminate the union.

Exhaustive Checks

A switch over a discriminated union can prove it handled every case: whatever is left in default must be never.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default:
      throw new Error(`unhandled shape: ${JSON.stringify(shape satisfies never)}`);
  }
}

console.log(area({ kind: "square", side: 3 }));
$ npx tsc --noEmit --module nodenext exhaustive.ts
exhaustive.ts(13,64): error TS1360: Type '{ kind: "rectangle"; width: number; height: number; }' does not satisfy the expected type 'never'.

"rectangle" isn’t handled, so shape in default is still the rectangle type, not never. satisfies never performs the check inline without an extra variable. The throw also protects at runtime, for data that bypassed the types — for example a kind read from a JSON file.

Custom Error Classes

Subclasses of Error let catch blocks branch on the kind of failure with instanceof, and carry typed extra fields.

class AppError extends Error {
  constructor(message: string, readonly status = 500, options?: ErrorOptions) {
    super(message, options);
    this.name = new.target.name;
  }
}
class NotFoundError extends AppError {
  constructor(resource: string, id: string | number) {
    super(`${resource} ${id} not found`, 404);
  }
}
class ValidationError extends AppError {
  constructor(readonly fields: Record<string, string>) {
    super("validation failed", 422);
  }
}

function toResponse(err: unknown): { status: number; body: unknown } {
  if (err instanceof ValidationError) return { status: err.status, body: err.fields };
  if (err instanceof AppError) return { status: err.status, body: `${err.name}: ${err.message}` };
  return { status: 500, body: "internal error" };
}

console.log(toResponse(new NotFoundError("user", 7)));
console.log(toResponse(new ValidationError({ email: "must contain @" })));
console.log(toResponse(new TypeError("x is undefined")));

const wrapped = new AppError("loading config failed", 500, { cause: new NotFoundError("file", "app.json") });
console.log(wrapped.name, "<-", (wrapped.cause as Error).message);
$ npx tsc --noEmit --module nodenext errors.ts
$ npx tsx errors.ts
{ status: 404, body: 'NotFoundError: user 7 not found' }
{ status: 422, body: { email: 'must contain @' } }
{ status: 500, body: 'internal error' }
AppError <- file app.json not found

err.fields is only reachable in the ValidationError branch. Order matters: ValidationError is also an AppError, so checking the base class first would swallow it. new.target.name gives each subclass its own name without repeating it, and { cause } keeps the original error when rethrowing. Older articles add Object.setPrototypeOf(this, new.target.prototype) — that’s only needed when compiling to ES5, a target TypeScript 6 deprecates (--target es5 is now error TS5107 unless you opt back in with ignoreDeprecations).

A Worked Example: Validating Input with Collected Errors

A validator that returns every problem at once, typed as a Result, is friendlier to API clients than one that throws on the first.

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
interface NewUser { name: string; email: string; age: number }
type FieldError = { field: keyof NewUser | "root"; message: string };

function parseNewUser(raw: unknown): Result<NewUser, FieldError[]> {
  if (typeof raw !== "object" || raw === null) {
    return { ok: false, error: [{ field: "root", message: "expected an object" }] };
  }
  const o = raw as Record<string, unknown>;
  const errors: FieldError[] = [];
  if (typeof o.name !== "string" || o.name.trim().length < 2) errors.push({ field: "name", message: "at least 2 characters" });
  if (typeof o.email !== "string" || !o.email.includes("@")) errors.push({ field: "email", message: "must contain @" });
  if (typeof o.age !== "number" || !Number.isInteger(o.age) || o.age < 0) errors.push({ field: "age", message: "non-negative integer" });
  if (errors.length) return { ok: false, error: errors };
  return { ok: true, value: { name: o.name as string, email: o.email as string, age: o.age as number } };
}

for (const body of ['{"name":"Ada","email":"ada@example.com","age":36}', '{"name":"A","email":"nope","age":-1}', "[]", "null"]) {
  const r = parseNewUser(JSON.parse(body));
  if (r.ok) console.log("ok", r.value);
  else console.log("invalid", r.error.map((e) => `${e.field}: ${e.message}`).join("; "));
}
$ npx tsc --noEmit --module nodenext validate.ts
$ npx tsx validate.ts
ok { name: 'Ada', email: 'ada@example.com', age: 36 }
invalid name: at least 2 characters; email: must contain @; age: non-negative integer
invalid name: at least 2 characters; email: must contain @; age: non-negative integer
invalid root: expected an object

"[]" passed the object check — arrays are objects — and failed on each field instead; null hit the root check. field: keyof NewUser | "root" means a typo like "emial" in an error is a compile error. The as string casts at the end are the one weak point: they rely on the checks above, which the compiler can’t connect across the separate errors array.

Practice

1. Add a "triangle" shape with base and height and handle every case so exhaustive.ts compiles.

Solution
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.side ** 2;
    case "rectangle": return shape.width * shape.height;
    case "triangle": return (shape.base * shape.height) / 2;
    default: throw new Error(`unhandled shape: ${JSON.stringify(shape satisfies never)}`);
  }
}

console.log(area({ kind: "rectangle", width: 2, height: 5 }), area({ kind: "triangle", base: 4, height: 3 }));
$ npx tsc --noEmit --module nodenext practice-1.ts
$ npx tsx practice-1.ts
10 6

2. Write assertIsString(value: unknown, name: string): asserts value is string and use it to validate process.argv[2].

Solution
function assertIsString(value: unknown, name: string): asserts value is string {
  if (typeof value !== "string") throw new TypeError(`${name} must be a string, got ${typeof value}`);
}

const arg: unknown = process.argv[2];
try {
  assertIsString(arg, "argument");
  console.log(`hello, ${arg.toUpperCase()}`);
} catch (e) {
  console.log((e as Error).message);
}
$ npx tsc --noEmit --module nodenext --types node practice-2.ts
$ npx tsx practice-2.ts ada
hello, ADA
$ npx tsx practice-2.ts
argument must be a string, got undefined

With no argument, process.argv[2] is undefined and the assertion throws. With one, the value is narrowed to string after the call.

3. Rethrow a JSON.parse failure as an AppError with the original as its cause, and print both messages.

Solution
class AppError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
    this.name = new.target.name;
  }
}

function loadConfig(text: string): unknown {
  try {
    return JSON.parse(text);
  } catch (err) {
    throw new AppError("config is not valid JSON", { cause: err });
  }
}

try {
  loadConfig("{ port: 80 }");
} catch (e) {
  const error = e as AppError;
  console.log(`${error.name}: ${error.message}`);
  console.log("caused by:", error.cause instanceof Error ? error.cause.message : error.cause);
}
$ npx tsc --noEmit --module nodenext practice-3.ts
$ npx tsx practice-3.ts
AppError: config is not valid JSON
caused by: Expected property name or '}' in JSON at position 2 (line 1 column 3)

cause is typed unknown — anything can be a cause — so it still needs an instanceof check before reading message.

Frequently Asked Questions

Why is the caught error typed as unknown in TypeScript?
Since TypeScript 4.4, the useUnknownInCatchVariables option (enabled by strict mode) types caught errors as unknown rather than any. JavaScript allows throwing any value, not just Error objects, so unknown is the honest type and forces you to check before accessing properties.
What is a type guard?
A type guard is a function or expression that narrows a type within a conditional block. User-defined type guards use the 'value is Type' return type annotation to tell TypeScript what the function checks.
What is the Result pattern?
The Result pattern (also called Either) represents operations that can succeed or fail. Instead of throwing exceptions, functions return an object with either a success value or an error. This makes errors part of the function signature.
What is the difference between a type guard and an assertion function?
A type guard returns a boolean and narrows the value inside an if block. An assertion function, declared with asserts value is Type, returns nothing and throws when the check fails, so the value is narrowed for the rest of the scope after the call.
How do I keep the original error when rethrowing?
Pass it as the cause option: throw new Error("loading config failed", { cause: err }). The original error stays attached as error.cause, Node prints it in the stack trace, and the cause option needs a lib of ES2022 or later, which TypeScript 6's defaults already include.