Making decisions · Lesson 14 of 77 · project
Project: building a decision table
About 60 minutes.
By the end of this lesson you can
- Turn a set of written rules into an ordered decision table before writing any code.
- Order rules so that the most specific comes first and no row is unreachable.
- Give every possible input a defined answer, including the invalid ones.
- Check the finished code by taking one example from every row of the table.
Where bugs in conditions actually come from
Why it matters
You now have every tool this module offers: if / else, truthiness, && and || and ??, switch, and the conditional operator. Real bugs in real code are almost never caused by getting one of those wrong. They are caused by the shape of the decision as a whole.
Three failures cover nearly all of them. A rule that can never be reached, because an earlier, broader rule already claimed its inputs. An input that matches no rule at all, so the function quietly returns undefined. And two rules that both match, where which one wins depends on the order you happened to write them in - which means the behaviour was never decided, only inherited.
None of the three produces an error message. All three are invisible while you test the cases you were thinking about. The defence is not a new piece of syntax; it is doing the thinking in a table before you do it in code.
What a decision table is
Explanation
A decision table is a list of rows. Each row has a condition and the answer to give when that condition holds. You write it in your own words first - on paper, or as comments above the function - and only then translate it, one row per branch, in the same order.
The rules that make it worth doing are simple. Rows are tried from the top, and the first one that matches wins, exactly like an if / else chain. Every row must be reachable: if the rows above it already cover everything it describes, it is dead code. And the last row must be a catch-all with no condition, so that every input leaves with an answer.
The reason to write it before the code is that a table makes those properties visible. Looking down a column of conditions, you can see that 'weight over 30' has to come before 'domestic', and that nothing handles a negative weight. Looking at the same logic already spread across fifteen lines of braces, you cannot.
Guard clauses: get the impossible inputs out of the way first
Explanation
The first rows of almost every useful table are not about the interesting cases. They are about the inputs that should not exist: a weight that is negative, a value that is not a number at all, a name that was never supplied. Handling them first is called a guard clause, and the payoff is that every row below can assume the input is sane.
That is what keeps the interesting rules readable. If you do not guard first, every later row has to defend itself, and a rule that should read 'domestic and under a kilo' turns into 'a number, and not missing, and above zero, and domestic, and under a kilo'.
Two checks do most of the work. 'typeof value === "number"' asks whether you were given a number at all, since text arriving from a form or a file is the usual culprit. And Number.isNaN(value) asks whether the value is the special not-a-number result you met in module 1 - the thing you get back from a failed conversion. It deserves its own check because NaN is genuinely a number as far as typeof is concerned, and it is not equal to anything, including itself, so a comparison like 'value > 0' is simply false for it rather than being an error.
Check there: Number.isNaN returns true only when the value is the NaN value, without the type coercion performed by the global isNaN function.
Order is priority, and priority is a decision
A mental model
When two rows could both match the same input, the one you wrote first is the one that happens. That is not an accident of the language to be worked around - it is the mechanism you use to express priority, and the way to use it deliberately is to sort your rows from most specific to most general.
'Express delivery to a domestic address' is more specific than 'a domestic address', so it goes above it. 'Over 30 kilos' is more specific than any destination rule, because no destination can rescue a parcel that is too heavy, so it goes above all of them. Read your table top to bottom and each row should be narrower than the one below.
The check that catches the mistake: for each row, ask what an input would have to look like to reach it. If nothing can - because a broader row above already caught everything it describes - the row is unreachable and one of the two is wrong.
The row you forget is the one that ships
A common mistake
A function that ends without returning gives back undefined. Not an error, not a warning - undefined, quietly, which then travels through the rest of the program until it causes a confusing failure somewhere completely different.
That is why the last row of a table is always a catch-all with no condition. Whether it returns a safe default, or a value like null that plainly means 'no answer for this input', it must return SOMETHING chosen on purpose. Deciding what to do with an input you did not anticipate is part of the design, not an afterthought.
The related habit: when you add a new rule later, re-read the whole table rather than pasting the new row at the bottom. A row appended below the catch-all can never run, and a row that belongs above an existing one changes the answer for inputs you were not thinking about.
Your table is also your test list
Explanation
Once the table exists, testing stops being guesswork. Take one example input from every row - including every guard row - and check that each produces the row's stated answer. If a row has a boundary in it, such as 'up to 2 kilos', take two examples: one exactly on the boundary and one just past it. That is where errors hide, as the ticket-price exercise showed.
A row you cannot write an example for is unreachable. An example you cannot place in any row means the table is incomplete. Both discoveries are worth far more than the test itself, and you get them before the code exists.
This is the habit the testing module later formalises, and it works today with nothing but a function and a few calls to console.log.
Recap
Recap
Write the rows before the code: condition on the left, answer on the right. Guards first, then most specific to most general, then a catch-all with no condition so every input has an answer. Check every row is reachable, and take one example from each - two at every boundary - as your test list.
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.
From a table in comments to the code beneath it
The comment block is the table, written first. Read the six calls at the bottom and note that there is exactly one per row - that is not a coincidence, it is the method.
1// destination | weight | answer2// (any) | not a number | "unavailable"3// (any) | 0 or less | "unavailable"4// domestic | 1kg or under | "letter"5// domestic | over 1kg | "parcel"6// abroad | 2kg or under | "air-light"7// abroad | over 2kg | "air-heavy"8// anything else | "unavailable"9 10function shippingBand(destination, weightKg) {11 if (typeof weightKg !== "number" || Number.isNaN(weightKg)) {12 return "unavailable";13 }14 if (weightKg <= 0) {15 return "unavailable";16 }17 if (destination === "domestic") {18 return weightKg <= 1 ? "letter" : "parcel";19 }20 if (destination === "abroad") {21 return weightKg <= 2 ? "air-light" : "air-heavy";22 }23 return "unavailable";24}25 26console.log(shippingBand("domestic", 0.5));27console.log(shippingBand("domestic", 4));28console.log(shippingBand("abroad", 2));29console.log(shippingBand("abroad", 9));30console.log(shippingBand("moon", 1));31console.log(shippingBand("domestic", -1));- line 11
The guards come first, so every line below this point can treat weightKg as a usable number. The Number.isNaN check is separate because typeof NaN is "number".
- line 17
Two rows of the table collapse into one branch plus a conditional, because they share a destination and differ only in the boundary. The 1kg boundary appears exactly once.
- line 23
The catch-all. An unknown destination is not an error here - it is a row of the table with a chosen answer.
- line 30
One call per row, in table order. Running this list after any change is the cheapest regression test you will ever write.
What it prints
letterparcelair-lightair-heavyunavailableunavailable
A row that can never run
Nothing here is a syntax error and nothing warns you. The second rule is dead code, and the only symptom is that large orders silently get the wrong answer.
1function fee(amount) {2 if (amount > 100) {3 return 0;4 }5 if (amount > 500) {6 return -5;7 }8 return 5;9}10 11console.log(fee(600));12console.log(fee(150));13console.log(fee(20));- line 2
Broader than it looks: 'over 100' already includes every amount over 500.
- line 5
Unreachable. Any amount that could satisfy this has already been claimed and returned above, so this row never runs for any input at all.
- line 11
600 was meant to get the -5 rate. It gets 0 - and no error is reported anywhere. Swapping the two rows, most specific first, fixes it.
What it prints
005
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
Two functions, the same two rules, written in opposite orders. Predict all four lines - the inputs are chosen so that only one input can tell them apart.
function feeA(amount) { if (amount > 100) { return 0; } if (amount > 500) { return -5; } return 5;}function feeB(amount) { if (amount > 500) { return -5; } if (amount > 100) { return 0; } return 5;}console.log(feeA(600));console.log(feeB(600));console.log(feeA(150));console.log(feeB(150));
A function has three if branches that each return, and no final return. What does it give back for an input that matches none of them?
One row of your table says 'abroad, 2kg or under'. How many examples should you take from it, and which?
Write it yourself
Exercise · stretch · 12 tests
The whole module in one function
Write deliveryBand(destination, weightKg, express) returning the name of a delivery band. The rules, as the shipping team wrote them: nothing can be sent if the weight is not a number, is not-a-number, is zero or negative, or is over 30kg - answer 'unavailable'. A domestic parcel sent express is 'next-day'; any other domestic parcel is 'standard'. A parcel going abroad weighing 2kg or less is 'air-light', and any heavier parcel going abroad is 'air-heavy' - express makes no difference abroad. Any other destination is 'unavailable'. Write the table out as comments first, order the rows most specific first, and finish with a catch-all so no input is left without an answer.
deliveryBand returns one of 'unavailable', 'next-day', 'standard', 'air-light' or 'air-heavy'. Weight rules are checked before destination rules, so an invalid or over-30kg weight is 'unavailable' whatever the destination. The 2kg boundary is inclusive: exactly 2kg is 'air-light'. express only affects the domestic rows.
Define deliveryBand at the top level of your code — the tests call it by name.
Worth remembering
- In what order do the rows of a decision table have to be written?
Guards first, then most specific to most general. The first matching row wins, so a broad row placed above a narrow one makes the narrow one unreachable.
- Why must a decision table end with a row that has no condition?
Because a function that returns nothing produces undefined silently. The catch-all makes the answer for an unanticipated input a decision rather than an accident.
- Why is 'typeof value === "number"' not enough to prove a value is usable?
typeof NaN is "number". NaN survives that check and then fails every comparison, so guard with Number.isNaN as well.
- How do you turn a decision table into a test list?
One example per row, including the guard rows, and two at every boundary - the boundary value itself and the first value past it.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
MDN — Control flow and error handling
Check there: The guide's coverage of if...else chains and switch, including the note that an if/else chain evaluates its conditions in order and executes the statements of the first true condition.
Check there: typeof NaN returns "number", which is why a separate NaN check is needed.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown