RizTech Academy logo
RizTech Academy
Classes and ErrorsLesson 3 of 330 min

throw, try/catch, and your own Error types

Every module so far has produced errors and treated them as accidents. They are also a tool: a way for a function to refuse rather than quietly return something wrong. This lesson is about throwing them on purpose and catching them properly — and module 8 depends entirely on it.

Failing loudly beats failing quietly

function orderTotal(plates, rate) {
  return plates * rate;
}

console.log(orderTotal('three', 80));
NaN

No error. NaN flows onward, gets formatted as ₹NaN on a page, or arrives at a server as null — module 4's JSON table. The bug surfaces far from its cause.

function orderTotal(plates, rate) {
  if (typeof plates !== 'number' || Number.isNaN(plates)) {
    throw new TypeError('plates must be a number');
  }
  return plates * rate;
}

orderTotal('three', 80);
TypeError: plates must be a number

throw stops execution immediately and unwinds until something catches it. The failure is now reported where it happened, with a stack trace pointing at the caller.

try, catch, finally

try {
  const order = JSON.parse(text);
  console.log(order.plates);
} catch (error) {
  console.log(`Could not read the order: ${error.message}`);
} finally {
  console.log('Done.');
}

try runs; if anything throws, the rest of the block is abandoned and catch runs with the error. finally runs either way.

When something throws, the rest of the try block does not run. That is the point — you do not continue with a value you failed to produce.

finally is for cleanup, and it runs even after a return:

function f() {
  try {
    return 'from try';
  } finally {
    console.log('finally ran');
  }
}

console.log(f());
finally ran
from try

But a return inside finally replaces the one from try:

function g() {
  try {
    return 'from try';
  } finally {
    return 'from finally';
  }
}

console.log(g());
from finally

It also swallows a thrown error the same way. Never return from finally.

If you do not need the error object, the binding is optional:

try {
  JSON.parse(text);
} catch {
  console.log('Not valid JSON.');
}

What an error actually is

An Error is an object with three things worth knowing:

const error = new Error('Balance too low');

console.log(error.name);
console.log(error.message);
Error
Balance too low

stack is the third — a string of where it came from, written for you rather than for users.

The built-in types are worth recognising, because the name tells you the kind of mistake:

Type Means
Error The general one.
TypeError A value was the wrong type. null.x, calling a non-function.
ReferenceError A name does not exist. Usually a typo.
SyntaxError Unparseable. Includes JSON.parse failures.
RangeError A number outside the allowed range.

All of them extend Error, so instanceof Error is true for every one.

Your own error types

This is the place inheritance is unambiguously right — the parent has not changed in twenty years, and the whole point is to be a kind of Error.

class InsufficientBalance extends Error {
  constructor(shortfall) {
    super(`Short by ₹${shortfall}`);
    this.name = 'InsufficientBalance';
    this.shortfall = shortfall;
  }
}

try {
  throw new InsufficientBalance(40);
} catch (error) {
  console.log(error.name);
  console.log(error.message);
  console.log(error.shortfall);
  console.log(error instanceof Error);
}
InsufficientBalance
Short by ₹40
40
true

Three things you gained. instanceof lets a caller tell this failure from any other. shortfall carries structured data — the caller can say "add ₹40" rather than parsing a sentence. And the name appears in logs and stack traces.

Set this.name explicitly; without it the name stays 'Error'.

Catch only what you can handle

The most important rule, and the one most often broken.

try {
  doEverything();
} catch (error) {
  console.log('Something went wrong');
}

That catches your own typos, swallows them, and reports a ReferenceError in your code as a user-facing problem. You have made debugging harder while appearing to make the programme safer.

Check the type and re-throw the rest:

function pay(subscription, plates) {
  try {
    return subscription.deliver(plates);
  } catch (error) {
    if (error instanceof InsufficientBalance) {
      return `Please top up ₹${error.shortfall}.`;
    }
    throw error;
  }
}

The failure you anticipated is handled. Everything else continues upward, loudly, which is what you want — a TypeError from a bug in deliver must not be reported to a customer as a balance problem.

A bare catch that does nothing is almost always wrong:

try {
  save(order);
} catch {}

That is a programme that fails silently and lies about it.

Keeping the original: cause

When you catch an error and throw a more meaningful one, the original is usually still worth having:

try {
  JSON.parse(raw);
} catch (error) {
  throw new Error('Could not load settings', { cause: error });
}
catch (error) {
  console.log(error.message);
  console.log(error.cause.name);
}
Could not load settings
SyntaxError

Your message says what failed in your terms; cause keeps the technical detail for whoever debugs it. Without it you lose the original entirely.

Throw errors, not strings

try {
  throw 'Balance too low';
} catch (error) {
  console.log(typeof error);
  console.log(error.message);
}
string
undefined

Legal, and a bad idea. No name, no message, no stack trace — so you have no idea where it came from. And any catch written normally, expecting error.message, gets undefined.

Always throw new Error(...). When catching, remember a caught value is not guaranteed to be an Error, which is why library code often checks error instanceof Error before reading .message.

When not to throw

Exceptions are for the exceptional. A user typing letters into a number field is not exceptional — it is Tuesday.

function parsePlates(raw) {
  const value = Number(raw);
  if (raw.trim() === '' || Number.isNaN(value)) {
    return { ok: false, message: 'Enter a number.' };
  }
  return { ok: true, value };
}

Returning a result is often better for expected failures: no try/catch at every call site, and the caller cannot forget to handle it as easily as it can forget to catch.

Throw when a function genuinely cannot do its job and continuing would be wrong. Return a result when failure is a normal outcome the caller will handle immediately. Validation is usually the second; a missing configuration file is the first.

Check your work

orderTotal('three', 80) returns NaN with no error. Throwing a TypeError instead reports the problem where it happened.

When something in try throws, the rest of the block is skipped.

finally runs even after a return — finally ran prints before the returned value. A return inside finally replaces the one from try, so g() gives from finally. Never return from finally.

catch without a binding is legal when you do not need the error.

new Error('x') has name 'Error', message 'x' and a stack. All built-in types extend Error, so instanceof Error is true for a TypeError.

A custom error must set this.name, or it stays 'Error'. It gives you instanceof for the caller and a place to carry structured data like shortfall.

Catching everything swallows your own bugs. Check the type, handle what you expected, throw error for the rest.

throw 'a string' gives a caught value with typeof 'string' and error.message of undefined, and no stack trace. Always throw an Error.

{ cause: error } keeps the original, readable as error.cause.

The Subscription exercise. deliver throws two different types, and the caller handles exactly one of them:

deliver(plates) {
  if (typeof plates !== 'number' || plates <= 0) {
    throw new RangeError('plates must be positive');
  }

  const cost = plates * this.#rate;
  if (cost > this.#balance) {
    throw new InsufficientBalance(cost - this.#balance);
  }

  this.#balance -= cost;
  this.#history.push({ plates, cost });
  return this.#balance;
}
function pay(subscription, plates) {
  try {
    return subscription.deliver(plates);
  } catch (error) {
    if (error instanceof InsufficientBalance) {
      return `Please top up ₹${error.shortfall}.`;
    }
    throw error;
  }
}

From ₹500 at ₹80: two deliveries of 3 succeed, leaving ₹20; the third throws InsufficientBalance with shortfall 220, and pay turns that into "Please top up ₹220." — a number it read from the error rather than from the sentence. deliver(0) throws RangeError, which pay re-throws untouched, and so does a ReferenceError from a typo inside deliver. That is the whole point of checking the type: the caller handles the one failure it understands and gets out of the way of everything else.

Note get history() { return [...this.#history]; } — the same defensive copy as the closure version in module 3, for the same reason.

Practice

  1. Write orderTotal that returns NaN for bad input, then make it throw a TypeError instead. Compare how easy each is to diagnose.
  2. Wrap a JSON.parse of deliberately broken text in try/catch and print the message.
  3. Add a finally and confirm it runs on both the success and failure paths.
  4. Return from finally and watch it replace the try return. Then make try throw and watch finally swallow that too. Then remove it.
  5. Print name, message and stack for a caught error.
  6. Cause each of TypeError, ReferenceError and SyntaxError deliberately and confirm all three are instanceof Error.
  7. Write InsufficientBalance extends Error carrying a shortfall. Throw it, catch it, and use the number rather than the message.
  8. Write the bad catch. Catch everything in a block that has a typo in it, and watch your own ReferenceError reported as a user error. Then narrow it with instanceof and re-throw.
  9. Throw a string, catch it, and confirm there is no message and no stack.
  10. Re-throw with { cause: original } and print both messages.
  11. Harder. Take the Subscription class from the classes lesson and make deliver throw InsufficientBalance when the balance is short and RangeError when the plate count is zero or negative. Then write a caller that turns the first into a friendly message, lets the second through, and proves — with a deliberate typo inside deliver — that a genuine bug still reaches you rather than being reported as a balance problem.

That is module five. You can build your own types, you know a class is prototypes underneath and what that costs, and you can fail on purpose — which matters more than it sounds, because a function that refuses is far easier to work with than one that returns NaN.

Next module: text, numbers and dates — validating a pincode with a regular expression, getting rupees right to the paisa, and the timezone trap that makes deliveries arrive a day early.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship