// Sheets · Apps Script

Replace object-as-dictionary lookups with a Map.

Stop silent undefined returns in Apps Script by switching from plain-object lookups to Map, and normalizing keys with String() on both sides so numeric sheet IDs match string form parameters every time.

I built a lookup table as a plain JS object in Apps Script, and my keys work fine most of the time, but some lookups silently return undefined even though I can see the value in the sheet.

The script

copy · paste · trigger
lookupByMap.gs
Apps Script
// Build a Map from sheet column A (IDs) -> column B (labels)
// Key insight: getValues() returns numbers; e.parameter returns strings.
// Normalize both sides with String() so they always match.
function buildLookupMap() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Data');
  var rows = sheet.getRange(2, 1, sheet.getLastRow() - 1, 2).getValues();
  var map = new Map();
  for (var i = 0; i < rows.length; i++) {
    map.set(String(rows[i][0]), rows[i][1]);
  }
  return map;
}

function doGet(e) {
  var map = buildLookupMap();
  var label = map.get(String(e.parameter.id));
  if (label === undefined) {
    return ContentService.createTextOutput('not found');
  }
  return ContentService.createTextOutput(label);
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

Why object keys hide the type mismatch

When you build a plain-object dictionary from a sheet, getValues() hands you a 2D array of native JavaScript values. A cell containing 1042 arrives as the number 1042, not the string '1042'. When you write obj[rows[i][0]] = rows[i][1], JavaScript silently coerces that numeric key to a string on write, because object keys are always strings internally. That coercion makes it look like the lookup will work.

The mismatch surfaces on the read side. A form submit or a doGet handler delivers every query parameter as a string, so e.parameter.id is always '1042'. When you do obj[e.parameter.id], JavaScript looks for the string key '1042' and finds it because both sides coerced. Accidental parity. The first time I hit this, it was a CSV import where some IDs were stored as plain numbers and others as text-formatted numbers; half the lookups broke silently because the object's coercion behavior was inconsistent across the two source shapes.

Map.get() uses the SameValueZero algorithm, which does not coerce. map.get(1042) and map.get('1042') are two different lookups and the wrong one returns undefined with no warning. That strictness is exactly what you want, but only after you normalize both sides explicitly.

Normalizing keys on both sides with String()

The fix is two calls to String(): one when you populate the map with map.set(String(rows[i][0]), ...), and one when you query it with map.get(String(e.parameter.id)). Neither call is expensive. String() on a value that is already a string is a no-op, so the pattern is safe to apply unconditionally even if you are not sure which type will arrive.

Avoid parseInt() or Number() as the normalizing function. Numeric normalization works until someone stores a non-numeric ID like 'SKU-004' in the sheet, at which point Number('SKU-004') returns NaN and every lookup for that key silently misses. String normalization handles all ID shapes uniformly.

One practical note: if your sheet IDs were formatted as text in Sheets (the little green triangle in the corner), getValues() returns them as strings already. String() on a string is a no-op, so the code is still correct. The explicit wrap documents your intent to future readers, which matters more than the micro-optimization of skipping it.

Map gives you size, iteration order, and no prototype collisions

Beyond the type-safety gain, Map has concrete operational advantages over a plain object. map.size gives you an immediate row count to compare against sheet.getLastRow() - 1 as a sanity check during a build step. Iteration via map.forEach() or for...of preserves insertion order, which plain objects also do in V8 for string keys, but Map makes it a guaranteed part of the spec.

The more dangerous plain-object footgun is prototype pollution. If a sheet cell contains the string 'constructor' or 'toString', obj['constructor'] resolves to Object.prototype.constructor rather than your data, and obj.hasOwnProperty('toString') returns true even before you populate the map. Map has no prototype chain of its own, so map.get('constructor') returns undefined cleanly until you explicitly set it.

Apps Script runs on V8 since 2020, so Map, Set, and all ES6 collection types are fully available. There is no polyfill concern. The one thing Map cannot do that a plain object can is be passed directly to JSON.stringify(); if you need to serialize the lookup table, convert with Array.from(map.entries()) first.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Why does my object lookup work in the Script Editor but break when called from a form?
The editor's test runner lets you pass typed values, so your numeric key matches. A form submit delivers everything as strings via e.parameter, so the same key is now '1042' instead of 1042. Map with String() normalization makes both paths behave identically.
Does Map work in Apps Script or do I need a polyfill?
Map is fully supported. Apps Script migrated to the V8 runtime in 2020; no polyfill needed. If a project still runs on the legacy Rhino runtime (check Edit > Settings for 'Chrome V8 runtime' toggle), Map is not available there and String-keyed objects are your only option.
Can I use an object with String() coercion instead of switching to Map?
Yes. obj[String(rows[i][0])] on the write side and obj[String(e.parameter.id)] on the read side solves the immediate type-mismatch bug. Map is still preferable because it avoids the prototype collision risk and gives you map.size, but the String() normalization is the load-bearing fix regardless of which container you use.
My sheet IDs are formatted as text in Sheets — do I still need String()?
Yes, keep it. Text-formatted cells do return strings from getValues(), so String() is a no-op there. But if someone ever reformats the column, removes the text format, or pastes new data, the type silently changes and your lookup breaks again. The explicit String() call is cheap insurance and documents the contract.