TypeScript Exception Handling: try/catch and Custom Errors

TypeScript exception handling uses try, catch, finally and throw, with an unknown catch variable under strict. Examples cover custom Error classes, cause, instanceof and async errors.

TypeScript exception handling uses the JavaScript statements throw, try, catch and finally, so code throws an error object when something goes wrong, and a catch block further up the call stack handles it. TypeScript adds type checking on top. The most visible difference from Java is that the catch variable has the type unknown, because JavaScript can throw any value, so we must check what we caught before we read message or any other property. There are no checked exceptions and no throws clause, so a function signature does not say what it may throw.

We use exception handling to report invalid input, such as an age typed as abc in a sign-up form, and to recover from failed calls, such as a REST request that returns an error.

The following example shows the pattern that most TypeScript projects follow, with a custom error class, a catch that narrows the error, a cause that keeps the original error and an await inside try. It uses TypeScript 7.0 and Node.js 22.

// 1. Custom error class
class AgeError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
    this.name = "AgeError";
  }
}

// 2. Throw an Error object
function parseAge(text: string): number {
  if (!/^\d+$/.test(text)) {
    throw new AgeError("Invalid age: " + text);
  }
  return Number(text);
}

// 3. try / catch / finally; err is unknown
try {
  parseAge("abc");
} catch (err) {
  if (err instanceof AgeError) {
    console.log(err.message);                 // Invalid age: abc
  } else {
    throw err;                                // rethrow what we cannot handle
  }
} finally {
  console.log("done");                        // always runs
}

// 4. Wrap a low-level error with cause (ES2022)
try {
  JSON.parse("{bad");
} catch (err) {
  const wrapped = new AgeError("Cannot read ages", { cause: err });
  console.log(wrapped.cause instanceof SyntaxError); // true
}

// 5. Async errors with await
async function loadAge(name: string): Promise<number> {
  throw new AgeError("No age for " + name);
}
try {
  await loadAge("John");
} catch (err) {
  console.log((err as Error).message);        // No age for John
}

Notice that the first catch block checks err instanceof AgeError before it reads err.message, and throws any other error again.

We start with the execution order of try, catch and finally and with the unknown catch variable. After that, we cover built-in and custom error classes, the surprising parts of finally, async errors and a result type for expected failures.

1. How try, catch and finally Run

The try…catch statement runs the try block first. If any line in it throws, JavaScript skips the rest of the block and jumps to catch, and the finally block runs last in every case.

  • After a normal finish
  • After a caught error
  • Even when catch throws again

A try needs at least one of catch or finally.

try {
  console.log("1. try");
  JSON.parse("{bad");
  console.log("never printed");
} catch (err) {
  console.log("2. catch: " + (err instanceof SyntaxError)); // 2. catch: true
} finally {
  console.log("3. finally");
}
console.log("4. after");

JSON.parse() throws a SyntaxError for invalid JSON, so the line “never printed” never runs. Because catch handled the error, the program continues after the statement and prints the fourth line. Without a catch block, the error would continue up the call stack after finally ran. If nothing catches it, Node.js prints the stack trace and exits.

2. The catch Variable Is unknown

JavaScript allows throw with any value, such as an Error, a string, a number or even undefined. TypeScript cannot know what a try block may throw, so since TypeScript 4.4 the catch variable has the type unknown when the useUnknownInCatchVariables option is on. The option is part of strict, so every project with “strict”: true gets it. Before 4.4, and with the option off, the variable was any.

With unknown, reading a property without a check is a compile error. TypeScript also rejects any type annotation other than any or unknown, because it would be an unchecked claim.

catch (err) { console.log(err.message); }
  error TS18046: 'err' is of type 'unknown'.

catch (err: Error) { }
  error TS1196: Catch clause variable type annotation must be 'any' or 'unknown' if specified.

Writing catch (err: any) still compiles and turns off the check for that block, which brings back the old risk, because err.message is undefined at runtime when a string was thrown. The safe way is to narrow the type with a check, as the next snippet does.

// 1. Narrow with instanceof
try {
  JSON.parse("{bad");
} catch (err) {
  if (err instanceof Error) {
    console.log(err.name);                    // SyntaxError
  }
}

// 2. Anything can be thrown
try {
  throw "apple";
} catch (err) {
  const isString = typeof err === "string";   // isString = true
}

// 3. A helper for the message of any thrown value
function errorMessage(err: unknown): string {
  return err instanceof Error ? err.message : String(err);
}
try {
  throw 404;
} catch (err) {
  const message = errorMessage(err);          // message = "404"
}

// 4. Catch without a variable (ES2019)
let valid = true;
try {
  JSON.parse("{bad");
} catch {
  valid = false;                              // valid = false
}

Inside the if (err instanceof Error) block, TypeScript treats err as an Error, so properties such as message and stack are available. The same narrowing works with the instanceof operator anywhere in our code. A small helper like errorMessage() is common in real projects for logging. When we do not need the error at all, step 4 shows that the variable can be left out.

3. Built-in Error Types and Throwing Errors

JavaScript defines a few Error subclasses that the runtime throws itself. We can also throw them from our own code when one of them describes the problem.

ClassThrown by the runtime whenExample
SyntaxErrorText cannot be parsedJSON.parse(“{bad”)
TypeErrorA value has the wrong type for an operationReading a property of null
RangeErrorA number is outside the allowed range(37).toFixed(500)
ReferenceErrorA variable name does not existUsing an undeclared variable
ErrorBase class; we throw it for general failuresnew Error(“Not found”)

We always throw Error objects (or subclasses), not strings or plain objects. Only Error objects record a stack trace, the list of function calls that led to the error, and only they work with instanceof checks and the errorMessage() helper from section 2.

function checkAge(age: number): number {
  if (age < 0 || age > 150) {
    throw new RangeError("Age out of range: " + age);
  }
  return age;
}

try {
  checkAge(200);
} catch (err) {
  if (err instanceof RangeError) {
    console.log(err.message);                 // Age out of range: 200
  }
}

The throw statement ends the function at once, like return, so the code after it runs only for valid ages. TypeScript knows that, which is why the function needs no else branch.

4. Custom Error Classes

Built-in types say little about our application’s failures. A custom error class, which is a class that extends Error, gives each kind of failure its own name, so catch blocks can tell the failures apart with instanceof. A custom error class can also carry extra fields, such as the name of the invalid input field. For example, a sign-up API throws a ValidationError with field set to “age”, so the form can highlight the age input.

// 1. Base class for our errors
class AppError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
    this.name = new.target.name;
  }
}

// 2. Subclasses with extra fields
class ValidationError extends AppError {
  field: string;
  constructor(field: string, message: string) {
    super(message);
    this.field = field;
  }
}
class NotFoundError extends AppError {}

// 3. Throw and inspect
function parseAge(text: string): number {
  if (!/^\d+$/.test(text)) {
    throw new ValidationError("age", "Invalid age: " + text);
  }
  return Number(text);
}

try {
  parseAge("abc");
} catch (err) {
  if (err instanceof ValidationError) {
    console.log(err.name);                    // ValidationError
    console.log(err.field);                   // age
    console.log(err.message);                 // Invalid age: abc
    console.log(err instanceof AppError);     // true
    console.log(err instanceof NotFoundError); // false
  }
}

Setting name matters, because stack traces and String(err) print it. The base class uses new.target, which refers to the class that was created with new, so every subclass gets its own class name without repeating the line. A minifier that renames classes also changes these names, so when we store or compare names, a fixed string such as this.name = “ValidationError” is safer.

Older articles add Object.setPrototypeOf(this, new.target.prototype) to every constructor. That fix was only needed when TypeScript compiled classes to ES5 functions, where instanceof failed for Error subclasses. With an ES2015 or later target, such as the ES2024 used here, classes compile to native classes and instanceof works without it.

4.1. Keeping the Original Error With cause

When we catch a low-level error and throw a more meaningful one, the original error should not get lost; it holds the real reason and its stack trace. The cause option, added in ES2022, stores it in the new error. The ErrorOptions parameter in our constructors passes it on to Error.

class ConfigError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
    this.name = "ConfigError";
  }
}

function readAges(json: string): Record<string, number> {
  try {
    return JSON.parse(json);
  } catch (err) {
    throw new ConfigError("Cannot read ages", { cause: err });
  }
}

try {
  readAges("{bad");
} catch (err) {
  if (err instanceof ConfigError) {
    console.log(err.message);                 // Cannot read ages
    console.log((err.cause as Error).name);   // SyntaxError
  }
}

The type of err.cause is unknown, for the same reason as the catch variable, so we narrow or cast it before use. Node.js prints the cause together with the stack trace when such an error is not caught. The cause option needs the ES2022 library or later in tsconfig.json (“lib” or “target”); with ES2021, new Error(“…”, { cause }) fails with TS2554: Expected 0-1 arguments, but got 2.

5. Handling Several Error Types With instanceof

A function often fails in more than one way, and the caller handles each case differently. A chain of instanceof checks in the catch block does that. The important part is the last line, which throws any error we did not expect again, so that a programming bug is not hidden behind a message such as “Unknown name”.

class ValidationError extends Error {
  name = "ValidationError";
}
class NotFoundError extends Error {
  name = "NotFoundError";
}

const ages = new Map([["Lokesh", 37], ["Raj", 35]]);

function findAge(name: string): number {
  if (name.trim() === "") {
    throw new ValidationError("Name is empty");
  }
  const age = ages.get(name);
  if (age === undefined) {
    throw new NotFoundError("Not found: " + name);
  }
  return age;
}

function describe(name: string): string {
  try {
    return name + " is " + findAge(name);
  } catch (err) {
    if (err instanceof ValidationError) {
      return "Bad input: " + err.message;
    }
    if (err instanceof NotFoundError) {
      return "Unknown: " + name;
    }
    throw err;
  }
}

const d1 = describe("Lokesh");                // d1 = "Lokesh is 37"
const d2 = describe(" ");                     // d2 = "Bad input: Name is empty"
const d3 = describe("John");                  // d3 = "Unknown: John"

The classes here set name with a class field instead of a constructor, which is the shortest form when a class needs no extra fields. The order of the checks matters when classes inherit from each other. A check for a base class such as AppError catches all its subclasses, so the more specific checks go first.

6. finally: Cleanup and Two Surprises

We use finally for code that must run whether the try block succeeds or not, such as closing a file, releasing a lock, stopping a timer or hiding a loading indicator. The finally block also runs when the try block returns, which confuses developers who expect return to leave the function at once.

// 1. finally runs even after return
function readAge(): number {
  try {
    console.log("open");
    return 37;
  } finally {
    console.log("close");                     // printed before the caller gets 37
  }
}
const age = readAge();                        // age = 37

// 2. A return in finally replaces the error
function swallow(): string {
  try {
    throw new Error("lost");
  } finally {
    return "finally wins";
  }
}
const result = swallow();                     // result = "finally wins"; the error is gone

Step 2 is a real bug pattern. A return (or a throw) inside finally replaces whatever the try or catch block was doing, including an error on its way to the caller. TypeScript compiles it without a warning; linters such as ESLint flag it with the no-unsafe-finally rule. We keep finally blocks for cleanup only.

7. Errors in Async Code

An async function never throws at the call site. When its body throws, it returns a rejected Promise instead. With await, the rejection turns back into a normal exception at the await line, so try/catch works the same way as for synchronous code. Without await, we handle it with the Promise method catch().

function wait(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
const ages = new Map([["Lokesh", 37], ["Raj", 35]]);

async function findAge(name: string): Promise<number> {
  await wait(50);
  const age = ages.get(name);
  if (age === undefined) {
    throw new Error("Not found: " + name);
  }
  return age;
}

// 1. await inside try/catch
try {
  await findAge("John");
} catch (err) {
  console.log(err instanceof Error ? err.message : err); // Not found: John
}

// 2. catch() on the Promise
const age = await findAge("John").catch(() => 0); // age = 0

// 3. Several calls: allSettled() keeps every result
const results = await Promise.allSettled([findAge("Lokesh"), findAge("John")]);
const states = results.map((r) => r.status);  // states = ["fulfilled", "rejected"]

There is one typing difference. The parameter of a catch() callback is any, not unknown, even under strict, because the Promise type in the standard library declares it that way. We apply the same instanceof checks there. Promise.allSettled() is useful when one failure should not cancel the other calls, for example when a dashboard loads several widgets and one of them fails, whereas Promise.all() rejects as soon as one call fails.

7.1. A Missing await Escapes try/catch

The most common async bug is calling an async function inside try without await. The call returns a Promise at once, the try block finishes without an error, and the rejection happens later, outside the block.

async function fail(): Promise<number> {
  throw new Error("async failure");
}

let caught = false;
let promise: Promise<number> | undefined;
try {
  promise = fail();                           // no await
} catch {
  caught = true;
}
console.log(caught);                          // false
await promise?.catch((err: Error) => console.log("handled later: " + err.message)); // handled later: async failure

The example handles the rejection afterwards so that the program can continue. If nothing handles the rejection, Node.js prints the error and exits with code 1, which is the default mode of the –unhandled-rejections option. TypeScript does not report a missing await, but the no-floating-promises rule of typescript-eslint does.

8. Returning a Result Instead of Throwing

Exceptions fit unexpected failures well, but for failures that are a normal outcome, such as invalid user input, some teams return a result object instead. A discriminated union (a union of object types that share a literal property, here ok) makes the caller check which case it got before it can read the value.

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

function parseAge(text: string): Result<number> {
  if (!/^\d+$/.test(text)) {
    return { ok: false, error: "Invalid age: " + text };
  }
  return { ok: true, value: Number(text) };
}

const r1 = parseAge("37");                    // r1 = { ok: true, value: 37 }
const r2 = parseAge("abc");                   // r2 = { ok: false, error: "Invalid age: abc" }
if (r2.ok) {
  console.log(r2.value);
} else {
  console.log(r2.error);                      // Invalid age: abc
}

Unlike a thrown error, the possible failure is part of the function’s return type, and TypeScript rejects r2.value until r2.ok has been checked. The check on ok narrows the union type, so the compiler knows which object shape we have in each branch. The cost is more code at every call site. We use result types for expected, frequent failures and keep exceptions for the rest.

9. Project Setup and Output

The exception-handling project on GitHub has one source file per article section, and a small runner calls the files one after another. The setup is TypeScript 7.0.2 on Node.js 22 or newer.

npm install
npm start

The console shows a header per section followed by the values from the snippet comments.

10. Conclusion

TypeScript exception handling is JavaScript’s try, catch, finally and throw with stricter types. The catch variable is unknown under strict, so we narrow it with instanceof before using it and rethrow what we cannot handle. Custom error classes that extend Error give failures a name and extra fields, and an ES2022 cause keeps the original error. The finally block is for cleanup only, since a return inside it hides errors. In async code, await inside try turns rejections back into exceptions, and a missing await is the bug to watch for.

11. References

MDN describes the JavaScript statements and error classes; the TypeScript documentation covers the typing of catch variables.

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.