ESC

Type to search the knowledge base.

Get and Set Nested Object Paths

Implement get(obj, path, fallback) and set(obj, path, value) for dot paths. Missing segments, non-object middles, and mutating vs cloning.

intermediate6 min read
  • javascript
  • objects
  • interview
  • lodash

user.address.city throws when address is missing. Optional chaining returns undefined and cannot write. Config code still needs get(obj, 'a.b.c', fallback) and set(obj, 'a.b.c', value) that walk a path string without exploding on a hole in the middle.

The interview is not whether you can split('.'). It is what you do when a segment is missing, when the middle value is a number or null, and whether set mutates the input. Those three decisions are the API. Optional chaining is the language’s read-only cousin; this page is the walk you write by hand.

  • get walks segments and returns fallback when the walk dies. undefined at the leaf uses fallback too; 0 and '' do not.
  • set must decide: create missing objects, throw, or no-op when a middle is not a container.
  • Mutating set edits the input. An immutable set copies only the path it touches.
  • Path is a convention ('a.b' or ['a','b']). Dots inside keys need the array form.
  • Don’t treat null as an object. typeof null === 'object' is the trap.

Walk, and decide what happens when the path dies

Normalize the path to an array of keys once. Accept both 'a.b.c' and ['a', 'b', 'c']. Empty segments from 'a..b' are real keys named ''; don’t filter(Boolean) them away unless you say you are collapsing dots.

get

function get(obj, path, fallback) {
  const keys = Array.isArray(path) ? path : String(path).split('.');
  if (keys.length === 1 && keys[0] === '' && path === '') {
    return obj === undefined ? fallback : obj;
  }

  let cur = obj;
  for (const key of keys) {
    if (cur == null || (typeof cur !== 'object' && typeof cur !== 'function')) {
      return fallback;
    }
    cur = cur[key];
  }
  return cur === undefined ? fallback : cur;
}

const data = { a: { b: { c: 1, z: 0 } } };
get(data, 'a.b.c'); // 1
get(data, 'a.b.missing', 42); // 42
get(data, 'a.b.z', 42); // 0  (valid falsy)
get(data, 'a.x.y', 42); // 42, no throw
get({ a: null }, 'a.b', 42); // 42

cur == null catches null and undefined. A number, string, or boolean in the middle also dies: get({ a: 1 }, 'a.toFixed') would otherwise pick up inherited methods if you kept walking. For config bags you usually want own-data walks, not Number.prototype.

fallback only replaces undefined, not null. get({ a: null }, 'a', 42) returns null. If you want nullish, compare with == null at the leaf and say so. Silent ?? behavior surprises callers who stored null on purpose.

Inherited keys: cur[key] reads the prototype. Use Object.hasOwn if the prompt wants own properties only. Own-only get treats toString on a plain object as missing.

set that mutates

function set(obj, path, value) {
  if (obj == null || (typeof obj !== 'object' && typeof obj !== 'function')) {
    throw new TypeError('set target must be an object');
  }

  const keys = Array.isArray(path) ? path : String(path).split('.');
  if (keys.length === 0) return obj;

  let cur = obj;
  for (let i = 0; i < keys.length - 1; i++) {
    const key = keys[i];
    const next = cur[key];
    if (next == null || typeof next !== 'object') {
      cur[key] = {};
    }
    cur = cur[key];
  }
  cur[keys[keys.length - 1]] = value;
  return obj;
}

const box = {};
set(box, 'a.b.c', 1);
box.a.b.c; // 1

When the path dies, this version overwrites a non-object middle with {}. set({ a: 1 }, 'a.b', 2) becomes { a: { b: 2 } } and the 1 is gone. That is hostile if a was money. The other legal choices:

Middle is 1 / null / missing set behavior
Create replace with {}, keep walking (above)
Throw TypeError('cannot set into a non-object')
No-op leave the input, return it unchanged

Create-as-you-go is what most whiteboard prompts expect. Throw is safer for untrusted paths. Pick one in the first sentence.

Immutable set

Mutating is wrong next to React state and next to a shared config object. Copy along the path; leave sibling keys on shared structure. That is the same idea as immutability patterns and deep vs shallow copy: you do not clone the world.

function setImmutable(obj, path, value) {
  const keys = Array.isArray(path) ? path : String(path).split('.');

  function branch(src, i) {
    const key = keys[i];
    const clone = Array.isArray(src)
      ? src.slice()
      : Object.assign({}, src != null && typeof src === 'object' ? src : {});
    clone[key] =
      i === keys.length - 1 ? value : branch(src?.[key], i + 1);
    return clone;
  }

  if (keys.length === 0) return obj;
  return branch(obj, 0);
}

const original = { a: { b: 1, c: 2 } };
const next = setImmutable(original, 'a.b', 9);
original.a.b; // 1
next.a.b; // 9
next.a.c; // 2
next.a === original.a; // false

Numeric segments ('items.0.name') become string keys on objects. If you want real arrays, detect ^\d+$ and allocate [] instead of {}. That is a product decision, the same family as flatten / unflatten objects. Don’t silently turn { '0': 'x' } into an array unless the prompt showed arrays.

Footguns in nested get and set

  1. typeof null === 'object'. Walking into null throws if you only check typeof === 'object'. Use == null first.
  2. Falsy leaves. 0, false, '' are values. Fallback is for undefined (and maybe missing keys), not for falsy.
  3. Prototype keys. get({}, 'toString') is a function unless you hasOwn.
  4. Dots in names. 'a.b' cannot name a key that contains a dot. Offer the array path.
  5. Frozen / sealed objects. Mutating set throws TypeError. Immutable set still works if you copy.
  6. Arrays as middles. Overwriting an array with {} because typeof is object but you assigned a non-index key is usually fine; overwriting it because the next key is '0' should keep the array.

get is a read. It should not create. A get that fills in {} as it walks is a set in disguise and will mutate objects during render.

Interview out loud

“I split a dot path, or accept an array of keys, and walk. get returns the fallback when a segment is missing, when the current value is nullish, or when the leaf is undefined. Zero and empty string are real values. set either mutates and creates intermediate objects, or copies the path for an immutable version; I will ask which. If a middle is a number or null, I overwrite with an object on the mutating helper and I will switch to throw if they want strict paths. I do not use optional chaining for the write, and I do not filter empty segments unless we agree dots collapse.”

Further reading

Related guides