JAVASCRIPT / CONDITIONALS
Lookup tables as an alternative to branching
Replace equality-only if/else chains with object or Map lookups, handle missing keys deliberately, and store functions in the table to dispatch behaviour.
What you will learn
- Turn an if/else chain that compares one value to literals into a single object lookup
- Handle table misses on purpose with Object.hasOwn, ?? or a Map default
- Store functions as table values so the table dispatches behaviour, not just data
- Keep if/else for ranges and compound tests that no single key can represent
Understanding Lookup tables as an alternative to branching
A chain of else-if branches that compares the same expression against a list of literal values is not really deciding anything; it is storing a mapping in the shape of control flow. The test method === 'express' and the result 12.5 are a key and a value written out as syntax. Put those pairs in an object and the chain collapses into one property read, and adding a fourth shipping method becomes adding a line of data instead of editing logic.
The mental model worth keeping is that a lookup table splits one decision into two independent halves: the mapping, which is now an ordinary value, and the selection, which is a single indexing expression. Because the mapping is a value, you can freeze it, iterate it, count it, merge two of them, or load it from JSON, none of which you can do with a chain of ifs. The price of that split is that keys must match exactly, so a table cannot express amount > 1000 or two conditions combined; when a branch tests a range or a compound condition, leave it as a conditional.
Two mechanical facts decide whether the table version is actually safe. Object keys are strings, so table[1] and table['1'] are the same entry and a numeric key quietly changes type, while a Map keeps every key exactly as given. A missing key also produces undefined instead of an error, so a table always needs a deliberate default, and a plain object literal inherits toString and constructor, which means an untrusted key can return something you never put in the table unless you check with Object.hasOwn, build the table with Object.create(null), or use a Map.
// The same decision written twice: as control flow, then as data.
function feeIf(method) {
if (method === 'standard') return 0;
else if (method === 'express') return 12.5;
else if (method === 'overnight') return 24;
return null;
}
const FEES = { standard: 0, express: 12.5, overnight: 24 };
function feeTable(method) {
return Object.hasOwn(FEES, method) ? FEES[method] : null;
}
for (const m of ['standard', 'express', 'drone', 'toString']) {
console.log(m, feeIf(m), feeTable(m));
}A branch chain that maps a fixed set of exact input values to results is data in disguise, so store it as an object or Map, index it once, and give an explicit answer for keys it does not contain.
Worked examples
Dispatch table of functions
Storing functions as the table values so the lookup selects behaviour instead of a constant.
const ops = {
add: (a, b) => a + b,
sub: (a, b) => a - b,
mul: (a, b) => a * b,
};
function apply(name, a, b) {
if (!Object.hasOwn(ops, name)) {
throw new Error(`unknown operator: ${name}`);
}
return ops[name](a, b);
}
console.log(apply('mul', 6, 7));
console.log(apply('sub', 10, 3));
try {
apply('constructor', 1, 2);
} catch (err) {
console.log(err.message);
}Example explained
Line 1ops holds three arrow functions, so the table stores behaviour and the lookup only picks which one to run.
Line 2Object.hasOwn(ops, name) rejects 'constructor' before any call; a typeof ops[name] === 'function' check would have let it through, because ops.constructor is the inherited Object function.
Line 3ops[name](a, b) evaluates only the selected function, so the arguments are used by exactly one branch.
Line 4The thrown Error replaces what would have been the final else of a chain: the table itself has no notion of a default.
Object keys are strings, Map keys are not
Shows why a table keyed by numbers behaves differently as an object literal than as a Map.
const byNumber = { 10: 'ten' };
console.log(typeof Object.keys(byNumber)[0]);
console.log(byNumber[10] === byNumber['10']);
const byValue = new Map([[10, 'ten'], ['10', 'string ten']]);
console.log(byValue.size);
console.log(byValue.get(10), byValue.get('10'));
console.log(byValue.get(3) ?? 'no entry');Example explained
Line 1typeof Object.keys(byNumber)[0] is 'string' because the numeric literal key 10 was converted to '10' when the object was built.
Line 2byNumber[10] === byNumber['10'] is true since both subscripts coerce to the same property name, so an object cannot tell the two apart.
Line 3The Map keeps 10 and '10' as separate keys, which is why size is 2 and each get returns its own value.
Line 4byValue.get(3) is undefined for a key that was never added, so the ?? supplies the default the table cannot.
Defaults: || versus ?? versus hasOwn
Demonstrates how a falsy value stored in the table breaks the usual || fallback.
const label = { ok: '', failed: 'Failed', pending: 'Pending' };
console.log(`[${label.ok || 'unknown'}]`);
console.log(`[${label.ok ?? 'unknown'}]`);
console.log(`[${label.missing ?? 'unknown'}]`);
console.log(Object.hasOwn(label, 'ok'), Object.hasOwn(label, 'missing'));Example explained
Line 1label.ok is an empty string, and || treats any falsy value as a miss, so the real entry is thrown away.
Line 2?? falls back only on null or undefined, so it lets the empty string through and the brackets print with nothing between them.
Line 3label.missing is genuinely undefined, so ?? is the right operator for a real absent key.
Line 4Object.hasOwn separates the question does the key exist from is the value truthy, which is the check you want when stored values may legitimately be falsy.
Important notes
Property order is insertion order for ordinary string keys, but keys that look like non-negative integers are visited first in ascending order, so do not rely on the literal order when iterating a numeric-keyed table.
An object literal evaluates all of its values when it is built, so a table like { premium: fetchRate() } does the work even for keys you never look up; store functions when the result must be computed lazily.
Common mistakes
Writing table[key] || fallback: a stored 0, empty string, or false counts as a miss, so a legitimate zero fee is reported as unknown.
Indexing a plain object with a key that came from user input: fees.constructor returns the Object function instead of undefined, and a typeof check accepts it and calls the wrong thing.
Declaring the table inside the function, which rebuilds the object and re-creates every stored function on each call, so nothing is actually reused and stored functions never compare equal between calls.
Try it yourself
Change, predict, then run
Rewrite a move(dir, x, y) function that handles 'up', 'down', 'left' and 'right' with an if/else chain so the four directions live in one object as [dx, dy] pairs. Return the unchanged coordinates for any other direction, and confirm move('constructor', 0, 0) gives [0, 0] instead of throwing.
Open the JavaScript workspaceCheck your understanding
You replace a branch chain with const text = labels[status] || 'unknown', where labels is { ok: '', failed: 'Failed' }. What does status of 'ok' produce, and why?
- 'unknown', because || falls back on any falsy value and the stored label for ok is an empty string
- '', because || falls back only when the property is missing from the table
- A TypeError, because an empty string is not a usable value in a lookup table
- 'unknown', because labels has no own property named ok
Show answer
|| tests the truthiness of whatever came out of the table, not whether the key was present, so the empty string stored under ok is discarded and replaced. The second option is the tempting one because it describes how ?? behaves with undefined, which is what people assume || does; and the last option misdiagnoses a real hit as a miss, since Object.hasOwn(labels, 'ok') is true.