Errors, validation, and defensive code · Lesson 46 of 77 · concept
Returning outcomes instead of throwing
About 50 minutes.
By the end of this lesson you can
- Decide whether a particular failure is exceptional or an ordinary outcome.
- Return a result object that makes a failure impossible to ignore by accident.
- Convert a throwing function into a result-returning one, and back again.
- State the price of the result style: every caller has to check.
Exceptional means unexpected, not bad
A mental model
A user typing two into a quantity box is not exceptional. It is Tuesday. It will happen thousands of times, and the program has a perfectly good answer for it: show them a message.
A configuration file the program cannot start without being absent is exceptional. There is no sensible way to continue, nobody downstream can do anything useful, and the right outcome is to stop loudly.
Throwing is for the second kind. It is a good tool precisely because it cannot be ignored — which is also exactly why it is the wrong tool for the first kind. Throwing on ordinary bad input forces every caller to write a try block around a situation that is not an emergency, and buries the normal case inside error-handling syntax.
The result shape
Explanation
The alternative is to return the outcome as data. A small object with a flag and either a value or a reason: { ok: true, value: 12 } on success, { ok: false, error: "quantity must be digits only" } on failure.
There is nothing clever about it. It is an ordinary object, it survives JSON.stringify unlike an error, it can be logged, stored or sent, and the caller reads it with an ordinary if. This pattern has names in other languages, but in JavaScript it is just a convention you and your codebase agree on.
The one rule that makes it work is that the flag, not the value, decides. Callers branch on result.ok and nothing else, because a value of 0, false, "" or undefined is a perfectly good successful answer, and testing the value for truthiness would call all four of them failures.
Why not just return null
Why it matters
The obvious shortcut is a sentinel: return null, or -1, or NaN, and let the caller check for it. It is shorter, and it loses two things you will want.
The first is the reason. null says something went wrong and stops there; the caller cannot tell the user whether the quantity was not a number or merely too large, so the message degrades to something unhelpful.
The second is worse, because it is silent. A sentinel is a real value and it collides with legitimate answers. find returns undefined when nothing matched — and also when the matching element was itself undefined. indexOf returns -1 for absent, and -1 is a number that arithmetic will happily use. A result object cannot collide with anything, because ok is a separate field from value.
A result nobody checks is worse than a throw
A common mistake
Be honest about the cost. A thrown error enforces its own handling: ignore it and your program stops. A returned result enforces nothing. If a caller uses result.value without looking at result.ok, they get undefined and carry on, and you have swapped a loud failure for a quiet one.
This is the trade at the heart of the lesson. Throwing buys enforcement and costs syntax. Returning results buys ordinary control flow and costs discipline, which has to come from code review, from types, or from the shape of the code itself.
One thing that helps: never give the result object a value property when ok is false. A failed result with no value at all turns a forgotten check into an obviously wrong undefined, immediately, rather than into a plausible default much later.
The adapter between the two styles
Explanation
Real code contains both styles, because it contains code you wrote and code you did not. The two meet at adapters, and there are exactly two of them.
One direction wraps a throwing function so its failure becomes data: call it inside a try block, return { ok: true, value } on success and { ok: false, error } on failure. This is what you write around somebody else's library, or around JSON.parse.
The other direction takes a result and turns it back into control flow: return the value when ok is true, and throw when it is false. That belongs at the point where a failure has stopped being ordinary — usually because everything upstream should already have checked it, so its arrival means a bug rather than a bad input.
Choosing between them
Recap
Throw when the failure means a bug, an impossible state, or a situation no caller can sensibly continue past. Wrong argument types, broken invariants, missing configuration at start-up.
Return a result when the failure is an ordinary outcome the caller will want to display or count. Parsing what a person typed, searching for something that may not exist, validating a form.
When you are unsure, ask who is going to read the message. If the answer is a user, it is data. If the answer is a developer at 2am, it is a throw.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
A result you cannot ignore by accident
The shape itself, with both branches, and a reminder that unlike an error it survives being turned into text.
1function divide(top, bottom) {2 if (bottom === 0) {3 return { ok: false, error: "cannot divide by zero" };4 }5 return { ok: true, value: top / bottom };6}7 8const good = divide(10, 4);9const bad = divide(10, 0);10 11console.log(good.ok);12console.log(good.value);13console.log(bad.ok);14console.log(bad.error);15console.log(JSON.stringify(bad));- line 3
The failure branch has no value property at all. A caller who forgets to check ok gets undefined immediately rather than a plausible zero.
- line 5
The success branch has no error property, for the same reason in reverse. Each shape carries only what is meaningful.
- line 15
Unlike an error object, a result serialises properly — it is a plain object with enumerable own properties, so nothing is silently dropped.
What it prints
true2.5falsecannot divide by zero{"ok":false,"error":"cannot divide by zero"}
The collision that makes sentinels unsafe
Two completely different questions, one answer. This is the failure mode a result object is designed to make impossible.
1const readings = [12, undefined, 30];2 3const missing = readings.find(function (value) {4 return value === undefined;5});6 7const absent = readings.find(function (value) {8 return value === 999;9});10 11console.log(missing === absent);12console.log(typeof missing);13console.log([10, 20].indexOf(99));- line 3
This search succeeds: the array really does contain undefined at position 1, and find returns it.
- line 7
This search fails: nothing matches, so find returns undefined to say so.
- line 11
true. A successful search and a failed one produced the identical answer, and no amount of checking can tell them apart afterwards.
- line 13
indexOf has the same problem in a different currency: -1 means absent, and it is a perfectly usable number that arithmetic will not question.
What it prints
trueundefined-1
Wrapping a function that throws
The adapter in the harder direction: a try block that turns any failure, including a thrown non-error, into data.
1function attempt(work) {2 try {3 return { ok: true, value: work() };4 } catch (error) {5 return { ok: false, error: error instanceof Error ? error.message : String(error) };6 }7}8 9console.log(JSON.stringify(attempt(function () { return 2 + 2; })));10console.log(JSON.stringify(attempt(function () { throw new RangeError("too big"); })));11console.log(JSON.stringify(attempt(function () { throw "plain string"; })));- line 3
work() is called inside the try block, so anything it throws is caught here rather than escaping to the caller.
- line 5
The instanceof test is not optional. A thrown string has no .message, and without this check the error field would read undefined.
- line 11
A deliberately badly behaved function, thrown a bare string. The adapter still produces a usable result rather than passing the mess along.
What it prints
{"ok":true,"value":4}{"ok":false,"error":"too big"}{"ok":false,"error":"plain string"}
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
Which of these failures is the best candidate for throwing rather than reporting?
find is asked for an element that is not there. Predict all three lines before revealing them.
const found = [1, 2, 3].find(function (n) { return n > 10;});console.log(found);console.log(typeof found);console.log(found === undefined);
What is the real cost of returning result objects instead of throwing?
Write it yourself
Exercise · core · 12 tests
A quantity box that reports instead of throwing
A shop's order form sends its quantity as text, because that is what a text box produces. A shopper typing two is not an exceptional event, so this function does not throw: it reports. Write parseQuantity(text) returning a result object. - On success: an object with ok set to true and value set to the number. - On failure: an object with ok set to false and error set to exactly one of these, checked in this order: 1. "quantity must be text" when text is not a string, 2. "quantity must be digits only" when the trimmed text is not one or more digits, 3. "quantity must be between 1 and 999" when it is digits but outside that range. A successful result has exactly the keys ok and value; a failed one has exactly ok and error.
parseQuantity never throws, for any argument at all. Spaces at each end are trimmed before the digits check, so " 7 " succeeds. Text such as "12.5", "-3", "1e3" and text that is empty once trimmed is rejected as not digits only. Both 1 and 999 are accepted.
Define parseQuantity at the top level of your code — the tests call it by name.
Exercise · stretch · 12 tests
Turn a result back into control flow
Results and throws have to meet somewhere. Write unwrap(result), the adapter that converts a result object back into ordinary control flow at the point where a failure really has become exceptional. - If result is not a result object — meaning it is null, not an object, or its ok property is not a boolean — throw a TypeError whose message starts with "unwrap: ". - If result.ok is true, return result.value. - If result.ok is false, throw an Error whose message is result.error when that is a string, and exactly "unwrap: unknown failure" when it is not. Returning result.value must keep working when the value is 0, false or undefined. Those are legitimate answers, not failures.
unwrap returns exactly result.value for a successful result, including falsy values such as 0, false and undefined. For a failed result it throws an Error carrying the result's own error text. For anything that is not a result — null, a string, an object with no boolean ok — it throws a TypeError instead.
Define unwrap at the top level of your code — the tests call it by name.
Worth remembering
- When do you throw, and when do you return a result object?
Throw when the failure means a bug or an impossible state that no caller can sensibly continue past. Return a result when failure is an ordinary, expected outcome the caller will want to display — bad user input, a search with no match.
- What is wrong with reporting failure by returning null, -1 or NaN?
The sentinel carries no reason, and it collides with legitimate answers: find returns undefined both when nothing matched and when the matching element was itself undefined. A result object keeps the flag in a separate field from the value, so nothing can collide.
- Why must a caller branch on result.ok rather than on result.value?
Because 0, false, "" and undefined are all perfectly good successful values. Testing the value for truthiness reports four legitimate answers as failures; the flag is the only field allowed to decide.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: That find returns undefined when no element satisfies the callback — the same value it returns when the matching element is itself undefined, which is the sentinel collision this lesson relies on.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown