Error handling strategies in JavaScript come down to one decision made before any code runs: which failures are part of normal operation and which ones mean the program has a bug. The first kind deserves a friendly message and a fallback, the second deserves a stack trace and a fix.
What kinds of failure does a larger script have to survive?
A script that handles one file and prints one line does not need an error strategy. A script that reads configuration, calls a service and writes results has at least four kinds of failure to think about, and they call for different reactions.
| Failure | Example | How to react |
|---|---|---|
| Bad input | a port number that is not a number | Reject it and say which field was wrong |
| Missing resource | a config file that is not on disk | Fall back to a default and carry on |
| A dependency failed | a request came back with a server error | Retry, degrade, or report the failure |
| A bug in your code | reading a property of something that is not there | Let it surface, then fix the code |
Most scripts that feel fragile are not missing a catch block. They have one catch block that treats every failure the same way, so a typo in the middle of a function is reported to the user as a network problem.
How do you tell an expected failure apart from a bug?
By giving expected failures their own error type. If your own code throws a named error class, then the catch block can check what it caught and react differently to no such record
and to you passed a string where a number was expected
.
class NotFoundError extends Error {
constructor(id) {
super('no record with id ' + id);
this.name = 'NotFoundError';
this.id = id;
}
}
function parseId(raw) {
const id = Number(raw);
if (!Number.isInteger(id)) {
throw new TypeError('id must be a whole number, got: ' + raw);
}
return id;
}
async function loadRecord(id) {
if (id === 99) throw new NotFoundError(id);
return { id, name: 'record ' + id };
}
async function show(raw) {
try {
const id = parseId(raw);
const record = await loadRecord(id);
console.log('loaded', record.name);
} catch (error) {
if (error instanceof NotFoundError) {
console.log('nothing to show:', error.message);
return;
}
console.log('unexpected failure:', error.message);
}
}
show('7');
show('99');
show('seven');
The output shows all three calls handled by the same catch block, but not treated the same way:
unexpected failure: id must be a whole number, got: seven
loaded record 7
nothing to show: no record with id 99
The broken input is reported first because it fails before the first await, so its message is printed while the other two calls are still waiting for their promises. Order like that surprises people once, then they stop reading the output as a sequence.
Notice what the catch block did with the two cases. A missing record is an ordinary outcome, so the function returns quietly. A programming mistake gets its own line, so it is visible instead of being swallowed into a friendly message.
Why does a custom error class beat a thrown string?
Because a class carries structured information. It has a name, it can hold extra fields, and the catch block can test it with instanceof. A thrown string gives you nothing but text, and every catch block has to compare text to work out what happened.
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
function createAccount({ email, age }) {
if (!email.includes('@')) {
throw new ValidationError('email', 'needs an @ sign');
}
if (age < 13) {
throw new ValidationError('age', 'must be at least 13');
}
return { email, age, id: 'acc_' + email.length };
}
try {
createAccount({ email: 'adaexample.com', age: 20 });
} catch (error) {
console.log(error.name, '->', error.field + ':', error.message);
}
The output is:
ValidationError -> email: needs an @ sign
The field property is the part that pays off in a real application. A form can point at the offending input, a log line can be grouped by field, and a test can assert on the field without parsing the message.
Setting name on the class matters too, because it is what shows up in a log line and what tells you which class of problem you are looking at.
Where should the catch block live?
As close to the failure as possible when the failure is expected and local, and as far away as possible when it is not. Inner functions should throw; the outer layer decides what happens. A function that swallows an error deep inside a chain leaves nobody able to tell whether the data was missing or the logic was wrong.
async function readText(path) {
if (path !== 'app.json') throw new Error('config not found: ' + path);
return '{"port": 8080}';
}
async function loadConfig(path) {
const text = await readText(path);
return JSON.parse(text);
}
async function run() {
try {
const config = await loadConfig('app.json');
console.log('starting on port', config.port);
} catch (error) {
console.error('fatal:', error.message);
process.exitCode = 1;
}
}
run();
The output for a good run is:
starting on port 8080
readText and loadConfig do not catch anything. They throw, and the single catch block at the top of run decides what the program does about it. Point the call at a missing file and the same block reports the failure and marks the run as failed.
When is a result object better than an exception?
Exceptions are for the exceptional. When a failure is an ordinary answer to a question — is this a valid port number — returning a small object is often clearer than throwing, because the caller cannot forget to handle it.
function parsePort(raw) {
const port = Number(raw);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
return { ok: false, error: 'not a valid port: ' + raw };
}
return { ok: true, value: port };
}
const good = parsePort('8080');
console.log(good.ok ? 'using port ' + good.value : good.error);
const bad = parsePort('http');
console.log(bad.ok ? 'using port ' + bad.value : bad.error);
The output is:
using port 8080
not a valid port: http
The two approaches trade places depending on the situation:
| Situation | Use a thrown error | Use a result object |
|---|---|---|
| How often it happens | Rare | Common |
| Who reacts | A catch block further up | The caller, right there |
| Readability | Happy path stays clean | Every caller checks a flag |
| Good fit | A file that could not be read | Validating one field of a form |
A useful middle ground is a thrown error for anything the caller cannot possibly handle, and a result object for the validations that happen on every request.
How should a script report a failure to whoever ran it?
Three habits cover most of it. Send normal output to standard output and failures to standard error, so the two can be separated in a pipeline. Put the message first and the detail after it, so the first line of a log is the useful part. And give the process a non-zero exit status when it failed, so a script that runs on a schedule or in a pipeline can be monitored without reading the output.
The run function above already does all three: it prints the ordinary line with console.log, prints the failure with console.error, and sets process.exitCode to 1 so the shell knows the run failed. When a script runs in a browser instead of a terminal, the first two habits are the ones that apply, and the third becomes a state on your page that you can display.
An error-handling checklist for larger scripts
- Decide up front which failures are ordinary and which are bugs, and never mix the two in one catch block.
- Give expected failures their own error class so the catch block can tell them apart with
instanceof. - Let low-level functions throw and handle failures at the layer that knows what to do about them.
- Use result objects for validation that happens on every call, and exceptions for the rare path.
- Include the field or the record in the message so the person reading it knows what to fix.
- Report failures on standard error and set a failing exit status so automation can react.
- Never end a catch block with nothing in it; log, re-throw, or return a deliberate fallback.
Try it yourself: Write a function parseSettings(raw) that reads a JSON string and returns a result object with ok, and either a value or an error explaining which setting is wrong. Then write a wrapper loadSettings(path) that throws a typed error when the file is missing and returns the parsed object otherwise. Call the wrapper inside a try/catch that prints a different message for a missing file and for a bad value, then run it once with valid input, once with a file that does not exist, and once with a file containing invalid JSON, and check that all three cases produce a different and sensible line.