← Fundamentals
Guided lesson 6 questions · 6 languages

Propagation and unwinding

How an error travels outward through the calls that are waiting, and what each layer adds or loses.

An error travels outward through every call between the failure and the code that handles it. An exception removes all of those frames in one step; an error value travels only through the returns that each layer writes. Handle the error where something useful can be done, and add context on the way without losing the cause.

Before you begin

Why this matters

A wrong total with no error message is often an error that was caught in the wrong place. Knowing how an exception travels lets you see which lines are skipped, which catch receives it, and what it still carries when it gets there.

What this helps you reason about

  • Predict which frames a throw removes and which lines never run.
  • Spot a catch block that sits too far inside and hides a failure.
  • Wrap an error with the name of the operation, and keep the original as its cause.

A hedge planting plan asks how many plants each metre needs. For holly, the function at the bottom has no answer and throws. This lesson follows that exception up through the calls that were waiting for it.

Already know this? The short version
  • A throw unwinds: every frame between it and the nearest catch ends in one step, and none of their remaining lines run; finally blocks still run on the way.
  • The layers between write nothing to pass an exception on; any try/catch in them is a place where it can stop.
  • A catch one layer too far inside turns a failure into an ordinary value, such as 0, and the top never hears of it.
  • Handle an error where something useful can be done; when wrapping, write new Error(message, { cause: problem }) so the reason travels with it.

Go to the final check· Go to the practice tickets

Words in this lesson (6)
call stack
The frames of all calls that have not finished yet, with the newest on top. A new call adds a frame on top, and a return removes it. Only the newest call runs; each call below it waits for the call it made.
exception
A value that is thrown to stop the normal flow when an operation fails. In TypeScript, any value can be thrown, but code usually throws an Error object, which has a message.
try/catch
A statement that runs the code in try, and runs the catch block only if that code throws. The catch block receives the thrown value.
propagate
To pass a failure on to your own caller instead of handling it. An exception propagates by itself; a failure returned as a value propagates only when each caller returns it again.
unwinding
What happens after a throw: every frame between the throw and the catch that receives it ends at once, newest first, without running its remaining lines. finally blocks run on the way.
cause
The original error kept inside a new one, as in new Error("back hedge failed", { cause: problem }). The new error’s cause property gives it back.

Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.

01 / Leaving frames

Which calls does an error leave behind?

Errors as values or exceptions showed the two ways a failure reaches its caller: as a value the caller must look at, or as an exception that jumps up the call stack. This lesson follows one error through several layers: a function that fails, the functions that called it, and the code that finally handles it.

When perMetre throws, three calls are waiting: hedge waits for perMetre, plan waits for hedge, and the top level waits for plan. The top level has the only catch. So the exception ends perMetre, hedge, and plan together, in one step. This is unwinding: every frame between the throw and the catch is removed, newest first, and none of their remaining lines run.

main.ts
function hedge(plant: string, metres: number): number {
  const each = perMetre(plant); // throws for "holly"
  return each * metres; // never runs for holly
}

function plan(plant: string): number {
  const plants = hedge(plant, 4);
  return plants + 2; // never runs for holly
}

try {
  console.log("plants", plan("holly"));
} catch (problem) {
  console.log("plan stopped:", String(problem));
}

A throw removes every frame between it and the catch that receives it, in one step.

Coming from Go: There is no line where hedge returns the error. The next line that runs after the throw is in the catch block.

Experiment

Count the frames an error leaves

throw ends perMetre at once. hedge and plan have no catch, so their frames end too, without running their remaining lines. This is unwinding: the runtime removes every frame between the throw and the catch, newest first.

Why it matters With exceptions, the layers between the failure and the handler write nothing, and none of their remaining lines run. With error values, each layer runs its own return, and code before the test still runs.

Predict how many frames the frame list shows at the stop where the error starts, and how many at the next stop after that. Then add the line console.log("hedge got", each); directly after the call in hedge, and predict whether it prints.

function perMetre(plant: string): number {
  if (plant === "hazel") {
    return 3;
  }
  throw new Error("unknown plant: " + plant);
}

function hedge(plant: string, metres: number): number {
  const each = perMetre(plant);
  return each * metres;
}

function plan(plant: string): number {
  const plants = hedge(plant, 4);
  return plants + 2;
}

try {
  console.log("plants", plan("holly"));
} catch (problem) {
  console.log("plan stopped:", String(problem));
}
What to look for

Step into plan, hedge, and perMetre. At the stop on the throw line, the frame list shows four frames: perMetre on top of hedge · line 9, plan · line 14, and <top level> · line 19. The next stop is in the catch block, and only <top level> is left: three frames were removed in one step. The return lines of hedge and plan never ran. A const that has not run yet shows as “not initialized yet”, and the stepper sometimes stops on one line twice; both are normal. The run prints plan stopped: Error: unknown plant: holly. After the edit, the output is the same: the new line is never reached.

Expected output

plan stopped: Error: unknown plant: holly

02 / Each layer

What does each layer between write?

Nothing. A layer between the throw and the catch passes the exception on by not catching it. That is why hedge and plan above look like code that cannot fail.

The one thing that does run in those layers is a finally block. try { … } finally { … } runs its finally block after a normal end and also while an exception passes through. Cleanup on a failing path, a later lesson, follows this in detail.

main.ts
function hedge(plant: string, metres: number): number {
  try {
    return perMetre(plant) * metres;
  } finally {
    console.log("hedge closed"); // runs on both paths
  }
}

The layers between write nothing to pass an exception on; only their finally blocks run.

Precisely: what unwinding is not

Unwinding does not undo anything. A change a frame made before the throw, such as a push onto an array, stays made. The stack trace in the Error was recorded when new Error ran, so it still lists the frames that unwinding removed.

03 / Lost errors

How does an error get lost on the way?

An exception stops at the first catch it reaches. If that catch is in the wrong layer, the error never reaches the code that could report it.

Below, hedge catches the failure and returns 0. plan cannot tell 0 plants from a real answer. It adds 0 and returns a total that is too small, and the catch at the top never runs. The program prints plants 12, as if holly had been planned.

A catch is too far inside when its layer cannot do anything useful with the failure. Remove it, and the exception travels on to the top.

main.ts
function hedge(plant: string, metres: number): number {
  try {
    return perMetre(plant) * metres;
  } catch (problem) {
    return 0; // the failure ends here
  }
}

// plan(["hazel", "holly"]) gives 12, and nobody is told

A catch in a layer that cannot act on the failure hides it from every layer above.

Coming from Python: A catch that returns a default is the same as except: return 0. TypeScript gives no warning about it.

Experiment

Catch an error one layer too far inside

The nearest catch receives the exception. Here that catch is in hedge, one layer above the failure, and it turns the failure into 0. plan cannot tell 0 plants from a real answer, so the top layer’s catch never runs.

Why it matters An error is lost in the first layer that stops passing it on. Look for that layer first when a result is wrong but nothing reported a failure.

Predict what the program prints, and whether the top layer ever sees the failure. Then delete the try line and the whole catch block in hedge, keeping return perMetre(plant) * metres;, and predict again.

function perMetre(plant: string): number {
  if (plant === "hazel") {
    return 3;
  }
  throw new Error("unknown plant: " + plant);
}

function hedge(plant: string, metres: number): number {
  try {
    return perMetre(plant) * metres;
  } catch (problem) {
    return 0;
  }
}

function plan(plants: string[]): number {
  let total = 0;
  for (const plant of plants) {
    total = total + hedge(plant, 4);
  }
  return total;
}

try {
  console.log("plants", plan(["hazel", "holly"]));
} catch (problem) {
  console.log("plan stopped:", String(problem));
}
What to look for

Step into the second hedge call, with plant holly. perMetre throws, and the next stop is in hedge’s catch block: only perMetre’s frame was removed. hedge returns 0, plan adds it, and the run prints plants 12. The catch at the top level never runs. After the edit, the throw removes perMetre, hedge, and plan in one step, and the run prints plan stopped: Error: unknown plant: holly.

Expected output

plants 12

04 / Where to handle

Which layer should handle an error?

An error should be handled by the layer that can do something useful with it. Useful means one of three things: try another way, ask for different input, or report the failure to a person. A layer that can do none of these should pass the error on.

The bottom layer usually knows the reason but not the purpose: perMetre knows that holly is unknown, not which hedge was being planned. The top layer knows the purpose and can tell the user. The layers between usually only pass the error on, and perhaps add what they were doing.

Handle an error in the layer that can act on it; every other layer passes it on.

05 / Context

What should an error carry on the way up?

A message such as unknown plant: holly says why, but not where: which hedge was being planned? A layer can add that context. It catches the error, and throws a new one whose message names the operation.

Keep the original error inside the new one. new Error(message, { cause: problem }) stores the caught value in the new Error’s cause property. Without it, the reason is lost, and a reader of the log sees only back hedge failed.

Because the catch variable has the type unknown, test it with instanceof Error before you read its message or its cause.

main.ts
function hedge(name: string, plant: string, metres: number): number {
  try {
    return perMetre(plant) * metres;
  } catch (problem) {
    throw new Error(name + " failed", { cause: problem });
  }
}
// message: back hedge failed
// cause:   Error: unknown plant: holly

Add the operation’s name on the way up, and keep the original error as the cause.

Precisely: cause and older runtimes

The cause option was added in ES2022 and works in Node.js 16.9 and later. Node prints the cause of an uncaught Error after its stack trace, marked [cause].

Experiment

Keep the cause inside a wrapping error

new Error(message, { cause: problem }) makes a new Error and stores the caught value in its cause property. The new message names the operation; the cause keeps the reason.

Why it matters Wrap an error with the name of the operation that failed, and keep the original error inside it. The message then says where and why, and code further up can still look at the cause.

Predict both printed lines. Then delete , { cause: problem } from the throw in hedge, and predict the second line again.

function perMetre(plant: string): number {
  if (plant === "hazel") {
    return 3;
  }
  throw new Error("unknown plant: " + plant);
}

function hedge(name: string, plant: string, metres: number): number {
  try {
    return perMetre(plant) * metres;
  } catch (problem) {
    throw new Error(name + " failed", { cause: problem });
  }
}

try {
  hedge("front hedge", "hazel", 4);
  hedge("back hedge", "holly", 2);
} catch (problem) {
  if (problem instanceof Error) {
    console.log(problem.message);
    console.log("cause:", String(problem.cause));
  }
}
What to look for

Step into the second hedge call. perMetre throws, the next stop is in hedge’s catch block, and the throw there starts a new exception. The next stop is in the catch at the top level. The run prints back hedge failed, then cause: Error: unknown plant: holly. After the edit, the second line is cause: undefined: the new Error no longer holds the old one.

Expected output

back hedge failed
cause: Error: unknown plant: holly

06 / Try it

Can you follow a fresh example?

Predict every line of output, then compare with the answer. The finally block belongs to the try at the top level.

main.ts
function prune(tree: string): number {
  if (tree === "plum") {
    throw new Error("plum is too tall");
  }
  return 3;
}

function pruneRow(trees: string[]): number {
  let cuts = 0;
  for (const tree of trees) {
    console.log("tree", tree);
    cuts = cuts + prune(tree);
  }
  return cuts;
}

try {
  console.log("cuts", pruneRow(["apple", "plum", "pear"]));
} catch (problem) {
  console.log("row stopped");
} finally {
  console.log("ladder away");
}

What is printed, line by line?

Reveal the reasoning

tree apple, tree plum, row stopped, ladder away. prune throws for plum. The exception removes prune and pruneRow in one step, so the loop never reaches pear, and the cuts line never prints. The catch prints row stopped, and the finally block runs after it.

Practice tickets

  1. PROP-101 · Tree tag errors never say what was wrong → Free
  2. PROP-102 · A mistyped planter size still sends a short soil order → Sign in · Pro
  3. PROP-103 · Sold-out herbs end the order although the partner farm grows them → Sign in · Pro

Keep exploring

Cleanup on a failing path →

Primary references