Get a Nested Value by Path
Optional chaining handles statically-known paths, but dynamic dotted strings like `'user.address.city'` or `'items[0].id'` still need a resolver. This snippet walks from the simple dot-only `get`, to a path parser that understands bracket notation and array indices, to a sibling `set` that creates intermediate containers safely. Use them whenever a path comes from config, JSONPath-lite, or a CMS field.
681 views
5
function getDot(obj, path, fallback) {
if (obj == null) return fallback;
const keys = path.split('.');
let cursor = obj;
for (const key of keys) {
if (cursor == null || typeof cursor !== 'object') return fallback;
cursor = cursor[key];
}
return cursor === undefined ? fallback : cursor;
}
const data = { user: { address: { city: 'Paris' } } };
console.log(getDot(data, 'user.address.city')); // Paris
console.log(getDot(data, 'user.address.zip', 'N/A')); // N/A
console.log(getDot(data, 'user.missing.deep', null)); // nullWhen every path is dot-separated and indexes are not allowed, splitting on '.' and walking step by step is the cleanest implementation. The cursor == null || typeof cursor !== 'object' guard short-circuits as soon as the path leaves the object world, which is what callers expect when a key is missing. Returning the explicit fallback instead of undefined lets the caller distinguish "missing" from "set to undefined". Reach for this version when paths come from a config you control.
function get(obj, path, fallback) {
if (obj == null) return fallback;
const keys = Array.isArray(path)
? path
: path
.replace(/\[(\w+)\]/g, '.$1') // [0] -> .0
.split('.')
.filter(Boolean);
let cursor = obj;
for (const key of keys) {
if (cursor == null || typeof cursor !== 'object') return fallback;
cursor = cursor[key];
}
return cursor === undefined ? fallback : cursor;
}
const payload = {
items: [
{ id: 'a', tags: ['x', 'y'] },
{ id: 'b' },
],
};
console.log(get(payload, 'items[0].tags[1]')); // y
console.log(get(payload, 'items[1].tags', [])); // []
console.log(get(payload, ['items', 0, 'id'])); // a
console.log(get(payload, 'items[2].id', null)); // nullReal paths in the wild include array indices and bracketed keys: 'items[0].tags[1]'. Normalising [idx] to .idx before splitting lets a single loop walk both arrays and objects since arr[idx] and obj[idx] use the same property access. Accepting either a string OR an already-parsed array of keys is the trick that makes this composable with caller-side caching of parsed paths. The filter(Boolean) drops empty segments so leading dots and double dots don't produce blank lookups.
function setPath(obj, path, value) {
const keys = Array.isArray(path)
? path
: path.replace(/\[(\w+)\]/g, '.$1').split('.').filter(Boolean);
let cursor = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
const nextKey = keys[i + 1];
if (cursor[key] == null || typeof cursor[key] !== 'object') {
cursor[key] = /^\d+$/.test(nextKey) ? [] : {};
}
cursor = cursor[key];
}
cursor[keys[keys.length - 1]] = value;
return obj;
}
const form = {};
setPath(form, 'user.address.city', 'Paris');
setPath(form, 'tags[0]', 'admin');
setPath(form, 'tags[1]', 'editor');
console.log(JSON.stringify(form));
// {"user":{"address":{"city":"Paris"}},"tags":["admin","editor"]}A set is what get calls for next: most CMS forms, dotted-form-name parsers, and immutable-state utilities need to write to a deep path that may not exist yet. The trick is peeking at the next segment to decide whether to create an array (if the next key is numeric) or an object. This version mutates obj in place; for an immutable equivalent, clone the path with structuredClone first or build a new branch with spreads at each level. Pair this with the get from the previous accordion and you have lodash's get/set in 30 lines.
