Lesson 16 / 21
Error Handling
The flow of `try`/`catch`/`finally`, the `throw` statement, built-in error types, the error object's fields, the cause chain, and the choice between an error and a return value.
Contents
In earlier lessons, the try/catch structure was used to show error messages but was
never defined. This lesson completes the structure: the flow of the blocks, throwing
errors, built-in error types, and — most importantly — the decision of whether a
problem should be reported as an error or as a return value.
The Ways to Run Code lesson showed that an error object carries a name, a message, and a stack trace. Here, we will produce these objects ourselves.
The Flow of the Blocks
The try block contains the code being watched. If an error occurs, flow jumps to the
catch block. The finally block runs whether or not there was an error.
function attempt(value) { try { console.log("1 try start"); if (value < 0) throw new Error("negative value"); console.log("2 try end"); return "try return"; } catch (error) { console.log("3 catch:", error.message); return "catch return"; } finally { console.log("4 finally"); } } console.log(attempt(1)); console.log("---"); console.log(attempt(-1));
1 try start 2 try end 4 finally try return --- 1 try start 3 catch: negative value 4 finally catch return
Two observations matter. First, when an error occurs, the try block’s remaining
lines do not run; flow moves directly to catch. Second, the finally block runs
after the return statement but before the function actually returns. This is
why cleanup work is written there: closing an opened resource, releasing an acquired
lock.
Writing return inside finally overrides the return value:
function overrides() { try { return "try"; } finally { return "finally"; } } console.log(overrides());
finally
The same behavior also swallows a thrown error. This is why the finally block should
contain only cleanup — it should carry no statement that redirects flow.
If the error object is not needed, the catch binding may be omitted:
function isValidJson(text) { try { JSON.parse(text); return true; } catch { return false; } } console.log(isValidJson('{"a":1}'), isValidJson("{broken}"));
true false
Built-in Error Types
The standard defines several error types, and errors the language itself produces are among them:
const attempts = [ () => null.x, () => unknownName, () => ([].length = -1), () => (1234).toFixed(200), ]; for (const a of attempts) { try { a(); } catch (error) { console.log(error.name + ": " + error.message); } }
TypeError: Cannot read properties of null (reading 'x') ReferenceError: unknownName is not defined RangeError: Invalid array length RangeError: toFixed() digits argument must be between 0 and 100
TypeError reports that a value was not of the expected kind, ReferenceError that a
name could not be found, RangeError that a value was outside the allowed range.
SyntaxError is produced during parsing; as seen in the first lesson, it never starts
the program at all — its one exception is the case where a built-in method that parses
text produces it at runtime.
Error names are standard; messages are not. Message text varies across runtimes and across versions. This is why code should not branch on message text; it should look at the name, the type, or a field we add ourselves.
Throwing an Error
The throw statement can throw any value. What is thrown does not have to be an error
object, but it should be:
try { throw "plain text"; } catch (error) { console.log(typeof error, error); console.log("name:", error.name, "| stack:", typeof error.stack); } try { throw new Error("error object"); } catch (error) { console.log(typeof error, error instanceof Error); console.log("name:", error.name, "| stack:", typeof error.stack); }
string plain text name: undefined | stack: undefined object true name: Error | stack: string
When text is thrown, there is no name and no stack trace; where the error came from is lost. This course’s rule: every thrown value is an error object.
When wrapping a lower-layer error, the original error should be preserved. An error object can carry a cause field for this:
function parseTemperature(text) { const value = Number(text); if (Number.isNaN(value)) { throw new Error(`could not convert to number: ${JSON.stringify(text)}`); } return value; } function readRecord(line) { const [station, temperatureText] = line.split(";"); try { return { station, temperature: parseTemperature(temperatureText) }; } catch (error) { throw new Error(`record could not be read: ${station}`, { cause: error }); } } console.log(readRecord("A1;21.5")); try { readRecord("B2;abc"); } catch (error) { console.log(error.message); console.log("cause:", error.cause.message); }
{ station: 'A1', temperature: 21.5 }
record could not be read: B2
cause: could not convert to number: "abc"
The upper layer added its own context (which record), the lower layer’s information (the cause) was not counted as lost. This is the standard way to enrich error messages with context.
Marking Your Own Error Type
It should be possible to tell apart an error we produced ourselves; otherwise a
catch block swallows unexpected programming errors along with the code’s own errors.
The simplest way is to add a name and a code field to the error object:
function measurementError(code, message) { const error = new Error(message); error.name = "MeasurementError"; error.code = code; return error; } const e = measurementError("EMPTY_FIELD", "temperature field is empty"); console.log(e.name, "|", e.code, "|", e.message, "|", e instanceof Error);
MeasurementError | EMPTY_FIELD | temperature field is empty | true
Defining named error classes through inheritance — using class syntax — is the next course’s subject. In this course, a name and a code field are enough.
Applying It to the Measurement Script
The response to broken data can now be tied to a contract: every line is either accepted or rejected with a reason; no line silently turns into a wrong value.
const CODE_EMPTY = "EMPTY_FIELD"; const CODE_NOT_NUMBER = "NOT_A_NUMBER"; function measurementError(code, message) { const error = new Error(message); error.name = "MeasurementError"; error.code = code; return error; } function readTemperature(text) { if (text.trim() === "") { throw measurementError(CODE_EMPTY, "temperature field is empty"); } const value = Number(text); if (Number.isNaN(value)) { throw measurementError(CODE_NOT_NUMBER, `temperature could not be converted to a number: ${JSON.stringify(text)}`); } return value; } const lines = ["A1;21.5", "A2;", "B1;abc", "B2;0"]; const accepted = []; const rejected = []; for (const line of lines) { const [station, field] = line.split(";"); try { accepted.push({ station, temperature: readTemperature(field) }); } catch (error) { if (error.name !== "MeasurementError") throw error; rejected.push({ station, code: error.code, message: error.message }); } } console.log("accepted:", accepted); console.log("rejected:", rejected);
accepted: [
{ station: 'A1', temperature: 21.5 },
{ station: 'B2', temperature: 0 }
]
rejected: [
{
station: 'A2',
code: 'EMPTY_FIELD',
message: 'temperature field is empty'
},
{
station: 'B1',
code: 'NOT_A_NUMBER',
message: 'temperature could not be converted to a number: "abc"'
}
]
Three decisions separate this script from its earlier versions.
First, the 0 value in the B2 record was accepted. The distinction built in the
Truthiness lesson holds: zero is a valid measurement.
Second, the catch block recognizes its own error. The check
error.name !== "MeasurementError" rethrows an unexpected error. Without this line, a
TypeError arising, say, in the line.split call would also be counted as a
“rejected record,” and a programming error would look like a data error.
Third, the rejected list carries a reason. How many records were rejected and why can be read straight from the output.
Error or Return Value
Not every unusual situation is an error. The criterion is this: if the caller can anticipate the situation, a return value fits; if not, an error fits.
Asking for the average of an empty list is a foreseeable situation; the average
function in the Function Declaration and Expression lesson returned null for exactly
this reason. In contrast, finding a letter in a field that should be numeric is a
violation of the data’s contract; an error fits.
Both practices sit together in the script above: readTemperature throws an error, the
calling loop catches the error and turns it into a data structure — the rejected list.
The boundary is drawn by where the problem can be handled.
Errors are not used for flow control. Throwing an error to exit a loop, skip a value,
or signal a condition is both costly and hard to read; break, continue, and return
values are what this job is for.
Summary
- If an error occurs in the
tryblock, the remaining lines do not run;finallyruns in every case and afterreturnis evaluated. - Writing
returnorthrowinsidefinallyoverrides the return value and the error; the block should contain only cleanup. - Error names are standard, messages are not; branching is not done on message text.
- Every thrown value should be an error object; otherwise there is no name and no stack trace.
- Wrapped errors are preserved with the
causefield; your own errors are marked with a name and a code field, and unrecognized errors are rethrown. - A foreseeable situation is reported with a return value, a contract violation with an error.
Next Step
Control flow and functions are complete. In this topic’s examples, structures like
filter, map, split, and object spread were used unexplained. The next topic
defines these: arrays’ construction and transformation methods, object access
shortcuts, template literals, JSON conversions, and date–time representation. There,
the measurement list will turn into a complete data set that is read from text and
written back to text.
To keep your progress and take notes, Log in
My notes
Log in to take notes.