Before you begin
Why this matters
A failure that reaches the wrong place, or no place, gives a wrong answer that looks right. Knowing how TypeScript passes failures lets you see which lines are skipped and which code receives the failure.
What this helps you reason about
- Predict which lines run after a throw, and which catch block receives it.
- Spot a catch block that hides a bug it was not written for.
- Choose between throwing and returning a result, and turn one into the other.
A first-aid kit holds 3 plasters, and two patients need 2 each. The second take must fail. In TypeScript, that failure usually travels as an exception: it leaves the function at once and skips code until a catch block receives it.
Already know this? The short version
- TypeScript failures usually travel as exceptions: throw leaves the function at once, and every frame between the throw and the nearest catch ends without running its remaining lines (finally blocks still run).
- Any value can be thrown; under strict settings the catch variable has type unknown, so test it with instanceof Error or turn it into text with String.
- Nothing in a function’s type says that it throws, and a catch receives everything thrown in its try, including TypeErrors from bugs.
- A function can return a failure as a value instead, such as undefined or { ok: false, reason }; then the type shows it, and the compiler makes the caller test it.
Words in this lesson (5)
- 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.
- unknown
- A type for a value whose type is not known yet. The compiler accepts no use of the value until a check, such as typeof value === "number", has decided its type.
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 / Two ways
How can a failure reach the caller?
A function that cannot give its result must tell its caller. Languages use two ways.
In the first way, the failure is a value. The function returns in the normal way, and what it returns says that it failed. The caller must look at that value to notice the failure.
In the second way, the failure is an exception. The function does not return in the normal way. The failure jumps up the call stack: the frame of the failing function ends, then its caller’s frame ends, and so on, until the failure reaches code that catches it. The lines in between are skipped.
TypeScript uses exceptions. throw starts the jump. try { … } catch (problem) { … } runs the code in try, and runs the catch block only if something was thrown there; problem names the thrown value. Below, take throws an Error when the kit has too few plasters. String(problem) turns the Error into text.
function take(left: number, wanted: number): number {
if (wanted > left) {
throw new Error("need " + wanted + ", have " + left);
}
return left - wanted;
}
try {
console.log(take(3, 4));
} catch (problem) {
// prints failed: Error: need 4, have 3
console.log("failed: " + String(problem));
} A failure reaches the caller either as a value it must look at, or as an exception that jumps up the call stack. TypeScript uses exceptions.
Coming from Go: A failed TypeScript call does not return at all. There is no err to test after it; the next line that runs is in a catch block.
Experiment
Follow a throw up the call stack
throw ends take at once. treat has no catch of its own, so its frame ends too, before its console.log line. The nearest catch is at the top level, and the program continues there with the thrown Error.
Why it matters An exception skips every line between the throw and the catch, in every frame it leaves.
Predict which lines run after the second take fails, and what is printed. Then change treat(3) to treat(4) and predict again.
function take(left: number, wanted: number): number {
if (wanted > left) {
throw new Error("need " + wanted + ", have " + left);
}
return left - wanted;
}
function treat(left: number): number {
const afterFirst = take(left, 2);
const afterSecond = take(afterFirst, 2);
console.log("both patients treated");
return afterSecond;
}
try {
console.log("left: " + treat(3));
} catch (problem) {
console.log("failed: " + String(problem));
}
What to look for
Step into treat and into each take. The frame list shows take on top of treat, on top of <top level>. A const that has not run yet shows as “not initialized yet”, and the stepper sometimes stops on one line twice; both are normal. In the second take, left is 1 and wanted is 2, and the stepper stops on the throw line. The next stop is in the catch block at the top level: the take and treat frames are gone together, and the console.log line in treat never ran. There, problem shows Error: need 2, have 1, and the run prints failed: Error: need 2, have 1. After the edit, both takes succeed, treat prints both patients treated, and the run then prints left: 0.
Expected output
failed: Error: need 2, have 102 / Values
What does a failure look like as a value?
TypeScript can also give back a failure as a value. undefined says that there is no result, as in Absence, zero, and empty values, but not why. To carry a reason, return an object with a field that tells the alternatives apart, as in Alternatives and combinations: { ok: true, left } when it worked, and { ok: false, reason } when it failed.
The failure now shows in the function’s type. The caller tests result.ok, and the compiler refuses result.left until it has.
type Taken =
| { ok: true; left: number }
| { ok: false; reason: string };
function tryTake(left: number, wanted: number): Taken {
if (wanted > left) {
return { ok: false, reason: "need " + wanted };
}
return { ok: true, left: left - wanted };
}
const result = tryTake(3, 4);
if (result.ok) {
console.log(result.left);
} else {
console.log("failed: " + result.reason); // failed: need 4
} A failure returned as a value shows in the type, so the compiler can make every caller look at it.
Precisely: the library mostly throws
Most built-in functions throw: JSON.parse throws a SyntaxError for text that is not JSON. A rejected Promise carries its failure as a value, and await turns that value back into an exception.
03 / Exceptions
What happens when a failure jumps up the call stack?
throw accepts any value: an Error, a string, a number. An Error object also records a stack trace, the calls that were active when it was created. Because anything can be thrown, the catch variable has the type unknown under strict settings. Test it with problem instanceof Error before you read problem.message.
While an exception travels, each frame it leaves ends at once, with one exception to the rule: a finally block. try { … } finally { … } runs the finally block on both paths, after a normal end or while an exception passes. If no catch is found at all, the program stops and prints the error with its stack trace.
function treat(left: number): number {
try {
return take(left, 4);
} finally {
console.log("kit closed"); // runs on both paths
}
}
try {
treat(3);
} catch (problem) {
if (problem instanceof Error) {
console.log(problem.message); // need 4, have 3
}
} An exception ends every frame it passes through, after their finally blocks, until a catch receives it.
Precisely: throwing values that are not Errors
throw "short" is allowed, but a string has no stack trace and no message property, which is why the catch variable is unknown. Throw Error objects, or classes that extend Error.
04 / Costs
Where can each way hide a failure?
Each way has a cost. A failure returned as a value shows in the type, so the caller and the compiler can see it. But code can still drop it: a caller can test ok and then do nothing with the reason.
An exception cannot be dropped without a word: if nobody catches it, the program stops with a message. But nothing in the type of take says that it throws. The caller learns it from a comment, from the documentation, or from a crash.
The common TypeScript trap is a catch block that catches more than it was written for. It receives everything thrown inside its try: the failure you expected, and also a TypeError from a bug. One fixed message for every caught value turns a bug into a wrong message. Keep the try small, and test what you caught. Below, take throws its own class, ShortOfPlasters, which extends Error, and the catch throws anything else on.
class ShortOfPlasters extends Error {}
// take now throws new ShortOfPlasters(…) when the kit is short.
try {
console.log(take(kits[0].plasters, 2));
} catch (problem) {
if (!(problem instanceof ShortOfPlasters)) {
throw problem; // not ours: pass it on
}
console.log("not enough plasters");
} A value shows in the type but can be dropped; an exception cannot be dropped silently but does not show in the type, and a broad catch hides bugs.
Coming from Java: TypeScript has no checked exceptions and no catch by type: every catch receives every thrown value, and you test its class yourself.
Experiment
Catch more than you meant to
kits[1] is undefined, and reading .plasters of undefined throws a TypeError before take is called. A catch receives every value thrown inside its try, so it receives the TypeError too, and prints a message written for a different failure.
Why it matters A catch block does not know which failure it was written for. Keep the try small, and look at what you caught before you decide what it means.
Predict what is printed: is the kit short of plasters? Then change the line in the catch block to console.log("failed: " + String(problem)); and predict again.
function take(left: number, wanted: number): number {
if (wanted > left) {
throw new Error("need " + wanted + ", have " + left);
}
return left - wanted;
}
const kits = [{ plasters: 5 }];
try {
const rest = take(kits[1].plasters, 2);
console.log("treated; " + rest + " plasters left");
} catch (problem) {
console.log("not enough plasters");
}
What to look for
Step from the const rest line. The next stop is already in the catch block, and no take frame ever appears: take never ran. kits shows [{plasters: 5}], one kit, at index 0. The run prints not enough plasters. After the edit, the run prints failed: TypeError: Cannot read properties of undefined (reading 'plasters').
Expected output
not enough plasters05 / Converting
How do you turn one kind of failure into the other?
You can turn one kind into the other wherever the caller needs it. To turn an exception into a value, catch it close to the call and return a result. To turn a value into an exception, test it and throw.
Converting decides who handles the failure. Return a value where the caller can do something useful with it, such as trying another kit. Throw where no caller nearby can fix it. A catch that returns undefined drops the reason; return { ok: false, reason: String(problem) } when the caller needs it.
function tryTake(left: number, wanted: number) {
try {
return take(left, wanted);
} catch (problem) {
return undefined; // the reason is dropped here
}
}
const rest = tryTake(3, 4);
if (rest === undefined) {
throw new Error("cannot treat: kit short");
} Catch and return a value where the caller can act; test a value and throw where it cannot.
06 / Try it
Can you explain a fresh example?
Predict every line of output, then compare with the answer. The next lesson, Stack and heap, shows where values are stored while a program runs. Its link is at the end of the page.
function take(left: number, wanted: number): number {
if (wanted > left) {
throw new Error("need " + wanted + ", have " + left);
}
return left - wanted;
}
function treat(left: number): number {
console.log("start");
const rest = take(left, 4);
console.log("done");
return rest;
}
try {
console.log(treat(3));
} catch (problem) {
console.log("caught");
}
console.log("end");
What is printed, line by line?
Reveal the reasoning
start, caught, end. treat prints start and calls take, which throws. The exception ends take and treat at once, so done is never printed and treat never returns; console.log(treat(3)) prints nothing. The catch block prints caught, and the program then continues after the try statement and prints end.
Practice tickets
- ERR-101 · An unreadable weight gives a dose of 0 mg → Free
- ERR-102 · One unreadable drink hides the rest of the fluid chart → Sign in · Pro
- ERR-103 · Medicine packs vanish when a ward cabinet is full → Sign in · Pro