跳到正文
DEV Community· Alessio Michelini·· 2 小時前AI 評分60

查找表應使用 Map,而非 JavaScript 物件字面值

You should use Map instead of object literals for lookup tables

AI 導讀

Alessio Michelini 建議,查找表的鍵來自外部輸入時使用 JavaScript Map,避免普通物件沿原型鏈讀取未明確設定的屬性。文中以命令派發和狀態轉換示例説明,這類查找可能回傳 Object.prototype 的屬性並造成錯誤;這並非原型污染。若鍵由程式內部控制,普通物件仍可使用,亦可用 Object.hasOwn() 檢查自有屬性。

正文

A while ago I wrote an article about using object literals instead of switch statements, and I still think the idea behind it is a good one: map an input to an output, skip the long list of case and break, and keep your code short and readable.

But there's a catch I didn't cover back then, and a reader (thanks Dario!) pointed it out in the comments: plain objects don't only contain the keys you put in them.

So in this article I want to show you what the problem is, and why today I'd reach for a Map instead.

A quick recap

In the previous article we had something like this:

const getAnimalSound = animal => {
  const sounds = {
    dog: 'woof',
    cat: 'meow',
    cow: 'moo',
    default: 'unknown sound',
  };

  return sounds[animal] || sounds.default;
};

console.log(getAnimalSound('dog'));   // 'woof'
console.log(getAnimalSound('tiger')); // 'unknown sound'

Clean, short, and it works. Or at least it looks like it does.

So what's the problem?

Every object you create with {} inherits from Object.prototype. This means that when you read sounds[animal], if JavaScript doesn't find the key on the object itself, it goes up the prototype chain and looks there too.

And Object.prototype has quite a few properties on it, like constructor, toString, valueOf, hasOwnProperty and __proto__.

Let's see what happens if we pass one of those names to our function:

console.log(getAnimalSound('constructor')); // [Function: Object]
console.log(getAnimalSound('toString'));    // [Function: toString]
console.log(getAnimalSound('__proto__'));   // [Object: null prototype] {}

None of those return 'unknown sound'. Because the value we get back is truthy, the || sounds.default fallback never kicks in, and we return something completely unexpected.

Now, you might think "who would ever pass constructor as an animal?". And you are probably right, until that value comes from a query string, a form field, or the body of an API request. Then anybody can pass whatever they want.

It gets worse with functions

In the previous article we also used object literals to dispatch commands:

const executeCommand = command => {
  const commands = {
    greet: () => console.log('Hello there!'),
    farewell: () => console.log('Goodbye!'),
    default: () => console.log('Unknown command'),
  };

  (commands[command] || commands.default)();
};

Let's try a few "commands":

executeCommand('constructor');    // nothing happens, it silently calls Object()
executeCommand('toString');       // nothing happens either
executeCommand('hasOwnProperty'); // TypeError: Cannot convert undefined or null to object

The first two fail silently, which is the worst kind of bug, because you don't even know something went wrong. The last one crashes your function.

And with nested objects

The state machine example is my favourite one:

const stateTransitions = {
  idle: { start: 'loading' },
  loading: { success: 'loaded', error: 'error' },
};

const handleStateTransition = (currentState, action) =>
  stateTransitions[currentState]?.[action] || currentState;

console.log(handleStateTransition('constructor', 'name')); // 'Object'
console.log(handleStateTransition('idle', 'toString'));    // [Function: toString]

Yes, your component is now in the 'Object' state, or worse, its state is a function.

Note
To be clear, this is not prototype pollution. Prototype pollution happens when an attacker manages to write to Object.prototype, for example with obj[key] = value where key is __proto__. Here we are only reading, but it's still a real bug, and it's the reason why security linters like eslint-plugin-security flag obj[variable] lookups.

Enter Map

A Map is a built-in collection of key/value pairs, and the important difference is that map.get(key) only returns entries that you explicitly added. There's no prototype chain to walk through, so constructor is just a key like any other, and since you never added it, you get undefined.

Let's rewrite our first example:

const sounds = new Map([
  ['dog', 'woof'],
  ['cat', 'meow'],
  ['cow', 'moo'],
]);

const getAnimalSound = animal => sounds.get(animal) ?? 'unknown sound';

console.log(getAnimalSound('dog'));         // 'woof'
console.log(getAnimalSound('tiger'));       // 'unknown sound'
console.log(getAnimalSound('constructor')); // 'unknown sound' ✅
console.log(getAnimalSound('__proto__'));   // 'unknown sound' ✅

You might have noticed a few other small changes, and they are not random:

  • The Map is defined outside the function, so we don't rebuild it every time the function is called.
  • The fallback is no longer stored as a default key, so nobody can "select" it by passing 'default'.
  • I'm using ?? (nullish coalescing) instead of ||. With ||, a valid value like 0 or an empty string would be replaced by the fallback. With ??, the fallback is used only when the value is null or undefined.

Tip
If you already have an object and you don't want to rewrite it as an array of arrays, you can convert it with new Map(Object.entries(myObject)).

Dispatching functions

The same works for our commands:

const commands = new Map([
  ['greet', () => console.log('Hello there!')],
  ['farewell', () => console.log('Goodbye!')],
  ['time', () => console.log(`Current time: ${new Date().toLocaleTimeString()}`)],
]);

const unknownCommand = () => console.log('Unknown command');

const executeCommand = command => (commands.get(command) ?? unknownCommand)();

executeCommand('greet');          // 'Hello there!'
executeCommand('hasOwnProperty'); // 'Unknown command' ✅

And with parameters, like the calculator from the previous article:

const operations = new Map([
  ['add', (x, y) => x + y],
  ['subtract', (x, y) => x - y],
  ['multiply', (x, y) => x * y],
  ['divide', (x, y) => (y !== 0 ? x / y : 'Cannot divide by zero')],
]);

const calculator = (a, b, operation) => {
  const fn = operations.get(operation);
  return fn ? fn(a, b) : 'Unknown operation';
};

console.log(calculator(5, 3, 'add'));     // 8
console.log(calculator(5, 3, 'valueOf')); // 'Unknown operation' ✅

The state machine

For nested lookups you can nest Maps too:

const stateTransitions = new Map([
  ['idle', new Map([['start', 'loading'], ['reset', 'idle']])],
  ['loading', new Map([['success', 'loaded'], ['error', 'error'], ['cancel', 'idle']])],
  ['loaded', new Map([['refresh', 'loading'], ['clear', 'idle']])],
  ['error', new Map([['retry', 'loading'], ['clear', 'idle']])],
]);

const handleStateTransition = (currentState, action) =>
  stateTransitions.get(currentState)?.get(action) ?? currentState;

let state = 'idle';
state = handleStateTransition(state, 'start');    // 'loading'
state = handleStateTransition(state, 'success');  // 'loaded'
state = handleStateTransition(state, 'toString'); // 'loaded' (unchanged) ✅

I'll be honest, this one is a bit less pretty than the object version. We'll come back to that in a moment.

A bonus with TypeScript

If you use TypeScript, Map has another nice side effect: map.get() always returns T | undefined, so the compiler forces you to handle the missing case.

type Animal = 'dog' | 'cat' | 'cow';

const sounds = new Map<Animal, string>([
  ['dog', 'woof'],
  ['cat', 'meow'],
  ['cow', 'moo'],
]);

const getAnimalSound = (animal: Animal): string =>
  sounds.get(animal) ?? 'unknown sound'; // without ?? TypeScript will complain

When you don't need a Map

As always, I don't want you to take this as "never use objects again". There are cases where a plain object is perfectly fine.

When the keys never come from outside your code. If the key is a value you control, for example a TypeScript literal union that you pass around internally, the prototype issue can't really happen. In that case an object with satisfies gives you great type checking:

type Animal = 'dog' | 'cat' | 'cow';

const sounds = {
  dog: 'woof',
  cat: 'meow',
  cow: 'moo',
} satisfies Record<Animal, string>;

Just remember that TypeScript types disappear at runtime: if the value comes from a request or a form, you still need to validate it.

When you only need a quick safety check. If you want to keep the object syntax, Object.hasOwn() checks only the object's own properties, ignoring the prototype chain:

const getAnimalSound = animal =>
  Object.hasOwn(sounds, animal) ? sounds[animal] : 'unknown sound';

This is also a good option for deeply nested tables like the state machine, where nested Maps get a bit noisy.

When you need to serialise the data. JSON.stringify(new Map([['a', 1]])) returns {}, not what you expect. If your lookup table needs to be sent over the wire or saved as JSON, an object is easier to work with.

When switch is simply the better tool. As I mentioned in the previous article, if your conditions involve ranges, complex expressions or intentional fall-through, a switch is still the right choice.

Conclusions

Object literals are still a great idea to replace long switch statements, but when the key comes from user input, a plain object can return things you never put in it, like constructor or toString.

A Map fixes that by design: it only knows the keys you gave it, it works nicely with ??, and with TypeScript it forces you to handle the missing case.

So my rule of thumb today is simple:

  • If the key comes from outside your code, use a Map.
  • If the key is fully under your control, an object is fine, and Object.hasOwn() is a cheap safety net if you want one.

I hope this helped you to understand the hidden gotcha of object lookups, and as always any comments are welcome!

Disclaimer

Parts of this article were created with the help of AI.

來源:DEV Community · dev.to