Modelling real data without a browser · Lesson 56 of 77 · practice
Lookup tables: finding one thing quickly
About 55 minutes.
By the end of this lesson you can
- Explain why searching a list again and again gets slow, in plain terms.
- Build an object that maps an id to a record, and read from it.
- Say what happens to a property name that is not a string.
- Avoid the inherited-property trap when a key might be a word like toString.
The cost of looking things up in a list
Explanation
Finding one record in an array means walking it until you hit a match. For a list of ten, that is nothing. For a list of ten thousand, it is ten thousand comparisons — and if you do that inside a loop over another ten thousand things, you have just asked the machine to do a hundred million comparisons for a job that should have been instant.
This is the shape of a great many slow programs, and it is almost never noticed while the test data is small. It is worth learning the fix before you meet the problem, because the fix is easy and the diagnosis is not.
The fix is a LOOKUP TABLE, sometimes called an index: an object whose property names are the ids you search by, and whose values are the records themselves. Once it is built, finding a record is one step regardless of how many records there are, because reaching for a named property does not involve searching.
A phone book, not a pile of letters
An analogy
A pile of letters is an array. To find the one from Ada you go through them one at a time. Two hundred letters, two hundred looks, and adding a letter to the pile makes every future search a little slower.
A phone book is a lookup table. You do not read it from the start; you go straight to the name. A phone book with ten times as many names is not ten times slower to use, and that is the whole point.
The catch is the same in both worlds: somebody had to compile the phone book. Building the index is itself one walk through the list. So a lookup table pays for itself when you will look things up more than once — and costs you slightly when you only ever look once.
Property names are always text
Explanation
An object's property names are strings. (There is one exception, symbols, which you will not need for a long time.) Anything else you put inside the square brackets is converted to a string first, before the lookup happens.
That means table[7] and table["7"] are the same property, and so are table[true] and table["true"]. It is why Object.keys on an object you indexed by number gives you ["7"] with quotation marks, and it can be genuinely confusing the first time you see it.
Most of the time this is harmless, because ids from a database or an API are text anyway. Where it matters is when two different keys flatten to the same string. The number 1 and the text "1" become one property. Two different objects used as keys both become the text "[object Object]" and collide with each other. Lesson 4 is about the tool that fixes exactly this.
The trap: every object already answers to some names
A common mistake
A plain object is not empty even when you have put nothing in it. It inherits a handful of properties, among them toString, valueOf and constructor. So counts.toString on an object you just created does not give you undefined — it gives you a function.
This turns into a real bug the moment your keys come from data rather than from you. Counting words in a document that contains the word "constructor", or indexing users by a username someone chose, and suddenly a lookup that should have missed comes back with something bizarre, or an increment tries to add 1 to a function.
There are two clean ways out. Use Object.hasOwn(table, key) to ask specifically about the object's OWN properties, ignoring anything inherited. Or use a Map, which has no inherited keys at all and is the subject of the next lesson. Both are correct; which one you reach for depends on whether you also need the thing to be JSON-serialisable.
The check you should NOT rely on is if (table[key]), because that is also false for a stored 0, a stored empty string and a stored false. Asking whether a key EXISTS and asking whether its value is truthy are two different questions, and conflating them is a bug that hides until your data contains a zero.
Building one, and keeping it honest
A mental model
Building an index is always the same three steps: start with an empty object, walk the list once, and for each record set table[record.id] = record. Object.fromEntries does the same job in one call when you already have key/value pairs, and pairs with Object.entries, which goes the other way.
Decide what a duplicate key means before you build. Assigning the same property twice means the last one wins and the earlier record is gone without a word. Sometimes that is what you want; sometimes it means the ids were not unique after all and you would rather have found out. Whichever it is, it should be a decision you made and not a surprise.
And remember that an index is a derived value: it is a second view of the same records, not a second copy of the truth. If the underlying list changes, the index is stale. Build it close to where you use it, or rebuild it when the list changes — do not let a stale index float around a program pretending to be current.
Recap
Recap
Repeatedly searching an array costs one walk per search; a lookup table turns each search into a single named-property read, paid for by one walk to build it. Property names are always strings, so numeric keys come back as text. Plain objects inherit names such as toString, so use Object.hasOwn — or a Map — when the keys come from data. Treat an index as a derived view that can go stale.
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.
Searching every time, versus building an index once
Two ways to answer the same question. The first walks the list on every call; the second walks it once and then answers instantly, no matter how long the list gets.
1const products = [2 { sku: "A1", name: "Mug", pricePence: 850 },3 { sku: "B2", name: "Notebook", pricePence: 1200 },4];5 6const wanted = products.find((product) => product.sku === "B2");7console.log(wanted.name);8 9const bySku = {};10for (const product of products) {11 bySku[product.sku] = product;12}13 14console.log(bySku.B2.name);15console.log(bySku["A1"].pricePence);16console.log(JSON.stringify(Object.keys(bySku)));- line 6
find walks the array from the start and stops at the first match. Called once, this is the right tool and nothing here is wrong.
- line 9
The index starts empty. It is a plain object because the keys are already strings and we may want to serialise it later.
- line 11
One walk, one assignment per record. The property name is the sku, and the value is the whole record — not a copy of it, the same object.
- line 14
A named-property read. No searching happens: the engine goes straight to the property, whether the index holds two records or two million.
- line 16
Object.keys shows what the index is made of: the ids, as text. The output has no spaces after the comma because console.log renders arrays with JSON.stringify.
What it prints
NotebookNotebook850["A1","B2"]
Pairs in, object out — and what happens to a number
Object.fromEntries builds an object from key/value pairs in one call, and Object.entries takes it apart again. The second half shows the string conversion that catches everyone once.
1const rows = [2 ["gbp", "GBP"],3 ["usd", "USD"],4];5 6const codes = Object.fromEntries(rows);7console.log(codes.gbp);8console.log(JSON.stringify(Object.entries(codes)));9 10const byId = {};11byId[7] = "seven";12console.log(byId["7"]);13console.log(JSON.stringify(Object.keys(byId)));- line 6
Each inner array is one [name, value] pair. This is the one-call version of the loop in the previous example, and it pairs with Object.entries below.
- line 8
Object.entries goes the other way: an object back into an array of pairs. The two are exact opposites, which makes them handy for transforming an object with array methods.
- line 11
The key here is the NUMBER 7, but property names are text, so it is converted to "7" before the property is created.
- line 12
Which is why looking it up with the STRING "7" finds it. The two are the same property; there is no way to have both.
- line 13
And why Object.keys reports ["7"] with quotation marks. If you need keys that keep their type, you need a Map — the next lesson.
What it prints
GBP[["gbp","GBP"],["usd","USD"]]seven["7"]
The key that was already there
An object you have only put one thing into still answers to several names. This is the trap that turns a word-counter into a bug the day someone writes about a constructor.
1const counts = { ada: 2 };2 3console.log(counts.ada);4console.log(counts.grace);5console.log(typeof counts.toString);6 7console.log("toString" in counts);8console.log(Object.hasOwn(counts, "toString"));9console.log(Object.hasOwn(counts, "ada"));- line 4
A key that was never set gives undefined. That is the behaviour you expected, and it is what makes the next line surprising.
- line 5
toString was never set either, but it is inherited from every plain object, so it is a function. Adding 1 to it would produce nonsense rather than an error.
- line 7
The in operator says true, because it counts inherited names as well. This is why in is the wrong test for a lookup table.
- line 8
Object.hasOwn asks only about the object's OWN properties, so it correctly says false. This is the test you want.
- line 9
And it still says true for a key you really did set. One function, both questions answered correctly.
What it prints
2undefinedfunctiontruefalsetrue
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
After const table = {}; table[7] = "seven"; which of these is true?
An empty object is used as a counter. What do these four lines print, and which one is the surprise?
const counts = {};counts.apples = 2;console.log(counts.apples);console.log(counts.pears);console.log(typeof counts.toString);console.log(Object.hasOwn(counts, "toString"));
When is building a lookup table NOT worth it?
Write it yourself
Exercise · core · 7 tests
Build the phone book
Write indexBy(records, keyName) that turns a list of records into a lookup table: an object whose property names are the value of keyName on each record, and whose values are the records themselves. Walk the list once. If two records share a key, the later one wins. The array you were given must come back untouched.
Returns a new object with one entry per record, keyed by String(record[keyName]), holding the record itself. An empty list gives an empty object. Duplicate keys resolve to the last record seen. A key that was never present reads as undefined. The input array and its records are not modified. Property names are always text, so a numeric id appears as a string key.
Define indexBy at the top level of your code — the tests call it by name.
Worth remembering
- Why build a lookup table instead of calling find each time?
find walks the list on every call. An index costs one walk to build and then answers each lookup in a single named-property read, so it pays for itself as soon as you look things up more than once.
- What happens to a property name that is not a string?
It is converted to a string first, so table[7] and table["7"] are the same property and Object.keys reports "7" as text. Two values that flatten to the same text collide.
- Why is Object.hasOwn(table, key) safer than table[key] or key in table?
table[key] is falsy for a stored 0 or empty string, and in counts inherited names such as toString. Object.hasOwn asks only about the object's own properties, which is the actual question.
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: The Bracket notation section, which states that the expression inside the brackets is converted to a string (or a symbol) before the property is looked up.
Check there: That it builds an object from a list of key-value pairs, and that it is the reverse of Object.entries.
Check there: That it reports only the object's own properties and returns false for inherited ones such as toString.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown