JSON Nested Objects: Access, Mutation, Traversal & Cycle Detection

Last updated:

JSON nested objects are JSON values where keys map to other JSON objects or arrays, enabling hierarchical data structures up to any depth. Optional chaining (?.) in ES2020 eliminates null reference errors when accessing deeply nested paths — obj?.a?.b?.c returns undefined instead of throwing; nullish coalescing (??) provides defaults in a single expression. This guide covers dot-notation vs bracket-notation access, optional chaining, safe mutation with spread operators, recursive traversal, and cycle detection. TypeScript interfaces model nested structures with zero runtime overhead.

The most common mistake with nested JSON is direct mutation — obj.a.b = newVal silently corrupts shared state when the object is referenced in multiple places (e.g., React state, Redux store). The correct pattern uses spread at every level along the changed path, creating new object identities while sharing unchanged branches. For deep cloning, structuredClone() is the modern standard; JSON.parse(JSON.stringify(obj)) remains the universal fallback for plain JSON with no circular references. Recursive traversal and transformation unlock powerful use cases — schema validation, data normalization, key renaming, and value redaction — but require cycle detection when the input may contain circular references.

Accessing Nested JSON Properties Safely with Optional Chaining

The safest way to read deeply nested JSON is optional chaining (?.), which short-circuits to undefined the moment any segment is null or undefined. Dot notation (obj.a.b) and bracket notation (obj["a"]["b"]) both throw TypeError when an intermediate key is absent — a common bug when consuming API responses with optional fields. Pair ?. with nullish coalescing (??) to provide safe defaults without masking falsy-but-valid values like 0 or "".

// ── Sample nested JSON from an API ───────────────────────────────
const user = {
  id: 1,
  name: "Alice",
  address: {
    city: "New York",
    zip: "10001",
    geo: { lat: 40.71, lng: -74.01 },
  },
  profile: null,          // field exists but is null
  // billing is absent   // field is completely missing
};

// ── Dot notation — throws if intermediate is null/undefined ───────
// user.billing.cardType  → TypeError: Cannot read properties of undefined

// ── Optional chaining — safe at every level ───────────────────────
const city    = user?.address?.city;           // "New York"
const lat     = user?.address?.geo?.lat;       // 40.71
const billing = user?.billing?.cardType;       // undefined (no throw)
const profile = user?.profile?.avatar;         // undefined (null short-circuits)

// ── Nullish coalescing for defaults ──────────────────────────────
const zip     = user?.address?.zip ?? "00000";        // "10001"
const country = user?.address?.country ?? "US";       // "US" (key absent)
const score   = user?.stats?.score ?? 0;              // 0

// WARNING: use ?? not ||
// || treats 0 and "" as falsy — wrong for numeric/string JSON values
const count = user?.stats?.count || 10;   // BAD: 0 becomes 10
const count2 = user?.stats?.count ?? 10;  // GOOD: 0 stays 0

// ── Bracket notation with dynamic keys ───────────────────────────
const field = "city";
const val = user?.address?.[field];   // "New York"

// ── Optional method call ──────────────────────────────────────────
const upper = user?.name?.toUpperCase();   // "ALICE"
const tags  = user?.getTags?.();          // undefined if getTags absent

// ── Array element access ──────────────────────────────────────────
const data = { items: [{ name: "Widget" }, { name: "Gadget" }] };
const first = data?.items?.[0]?.name;    // "Widget"
const third = data?.items?.[2]?.name;    // undefined (no throw)

// ── Chaining with logical AND (pre-ES2020 fallback) ───────────────
// Equivalent to optional chaining for JSON (no falsy non-nullish objects)
const cityOld = user && user.address && user.address.city;   // "New York"

// ── lodash _.get for runtime-dynamic paths ────────────────────────
import _ from "lodash";

const path = "address.geo.lat";
const latVal = _.get(user, path, 0);     // 40.71
const missing = _.get(user, "billing.type", "none");  // "none"

// _.get also accepts array paths for keys containing dots:
_.get(user, ["address", "geo", "lat"]);  // 40.71

TypeScript with strict: true (which enables strictNullChecks) enforces that you handle undefined before using an optionally-chained value — the inferred type of user?.address?.city is string | undefined, requiring a ?? fallback or a narrowing check before passing to a function that expects string. This catches missing-field bugs at compile time rather than at runtime. The ?. operator is available in TypeScript 3.7+ and compiles down to && checks for older compilation targets.

Mutating vs Immutably Updating Nested Objects

Direct mutation of nested JSON (obj.a.b = newVal) is dangerous when the object is shared — React state, Redux store, or any reference passed to multiple consumers. Spreading at each level of the changed path creates new object identities while sharing all unchanged subtrees, satisfying reference-equality checks used by React and Redux to detect changes. The rule: spread every object on the path from root to the changed key.

// ── Mutable mutation — WRONG for shared state ─────────────────────
const state = { user: { name: "Alice", address: { city: "NYC" } } };
const bad = state;
bad.user.address.city = "Boston";     // mutates state.user.address too!
console.log(state.user.address.city); // "Boston" — shared reference corrupted

// ── Immutable update with spread ──────────────────────────────────
// Rule: spread every object on the path from root to the changed key
const state2 = { user: { name: "Alice", address: { city: "NYC" } } };

const next = {
  ...state2,                              // new root object
  user: {
    ...state2.user,                       // new user object
    address: {
      ...state2.user.address,             // new address object
      city: "Boston",                     // only city changed
    },
  },
};

// state2 is unchanged; next has new identity at each level
console.log(state2.user.address.city);   // "NYC"
console.log(next.user.address.city);     // "Boston"
// Unchanged subtrees share references — efficient for large objects
console.log(next.user.name === state2.user.name); // "Alice" — shared

// ── Array items: immutable update at index ────────────────────────
const items = [
  { id: 1, name: "Widget", qty: 2 },
  { id: 2, name: "Gadget", qty: 5 },
];

// Update qty for id=2 without mutating the array
const updatedItems = items.map((item) =>
  item.id === 2 ? { ...item, qty: item.qty + 1 } : item
);

// ── Runtime-dynamic path update ───────────────────────────────────
function setIn(obj, path, value) {
  const [head, ...tail] = path;
  if (tail.length === 0) return { ...obj, [head]: value };
  return { ...obj, [head]: setIn(obj[head] ?? {}, tail, value) };
}

const result = setIn(state2, ["user", "address", "zip"], "02101");
// { user: { name: "Alice", address: { city: "NYC", zip: "02101" } } }
// state2 is untouched

// ── immer: write mutations, get immutable result ──────────────────
// npm install immer
import { produce } from "immer";

const next2 = produce(state2, (draft) => {
  draft.user.address.city = "Boston";   // looks mutable — actually safe
  draft.user.address.zip  = "02101";    // immer creates structural sharing
});
// state2 unchanged; next2 is a new immutable object
console.log(next2.user.address.city);   // "Boston"

// ── Object.assign — shallow, same caveats as spread ──────────────
// Creates a new top-level object but nested refs are still shared
const shallow = Object.assign({}, state2, { score: 99 });
// shallow.user === state2.user — same reference

For deeply nested updates (5+ levels), the nested spread syntax grows unwieldy. Immer is the standard solution in React and Redux Toolkit — it intercepts mutations on a Proxy draft and produces a structurally-shared immutable result. Redux Toolkit's createSlice uses Immer internally, which is why reducers written with direct mutation syntax work correctly inside createSlice. The setIn helper above is useful when the update path is known only at runtime (e.g., form field names mapped to nested state paths).

Deep Clone Nested JSON with structuredClone() and JSON.parse()

A deep clone creates a fully independent copy of every nested object — modifications to the clone never affect the original. Three main approaches exist: structuredClone() (modern standard), JSON.parse(JSON.stringify()) (universal fallback for plain JSON), and lodash _.cloneDeep() (handles edge cases including circular references). Choose based on your environment and what the data contains.

// ── structuredClone() — built-in, Node.js 17+ / modern browsers ──
const original = {
  user: { name: "Alice", address: { city: "NYC" } },
  tags: ["admin", "editor"],
  createdAt: new Date("2025-01-01"),   // Date objects preserved!
  score: 42,
};

const clone = structuredClone(original);
clone.user.address.city = "Boston";

console.log(original.user.address.city); // "NYC" — untouched
console.log(clone.createdAt instanceof Date); // true — Date preserved

// structuredClone() supports: objects, arrays, Date, Map, Set,
// ArrayBuffer, RegExp, Blob — but NOT functions, DOM nodes, or symbols

// ── JSON.parse(JSON.stringify()) — universal, plain JSON only ─────
const plainJson = {
  user: { name: "Alice", address: { city: "NYC" } },
  tags: ["admin", "editor"],
  score: 42,
};

const clone2 = JSON.parse(JSON.stringify(plainJson));
clone2.user.address.city = "Boston";
console.log(plainJson.user.address.city); // "NYC" — untouched

// LIMITATIONS of JSON.parse/stringify:
// - undefined values are stripped (keys removed)
// - Date objects become strings: "2025-01-01T00:00:00.000Z"
// - Functions, symbols, and class instances are stripped
// - Circular references throw TypeError

const withUndefined = { a: 1, b: undefined, c: null };
JSON.parse(JSON.stringify(withUndefined)); // { a: 1, c: null } — b gone!

// ── Shallow copy — spread / Object.assign ─────────────────────────
const shallow = { ...original };
shallow.user.address.city = "Boston";
console.log(original.user.address.city); // "Boston" — shared reference!

// ── Performance comparison ─────────────────────────────────────────
// structuredClone: ~2–3× slower than JSON round-trip for large plain objects
// JSON round-trip: fastest for pure JSON, but loses non-JSON types
// _.cloneDeep:    handles circular refs and more types, but adds dependency

// ── lodash _.cloneDeep — handles circular references ─────────────
import _ from "lodash";

const circular = { name: "Alice" };
circular.self = circular;   // circular reference

const cloneCircular = _.cloneDeep(circular);
// Does not throw — circular ref is preserved in the clone
console.log(cloneCircular.self === cloneCircular); // true

// ── Manual deep clone (no dependencies) ──────────────────────────
function deepClone(value) {
  if (value === null || typeof value !== "object") return value;  // primitive
  if (Array.isArray(value)) return value.map(deepClone);
  return Object.fromEntries(
    Object.entries(value).map(([k, v]) => [k, deepClone(v)])
  );
}

const clone3 = deepClone(plainJson);
clone3.user.address.city = "London";
console.log(plainJson.user.address.city); // "NYC"

structuredClone() is the right default for modern applications — it is faster than JSON.parse(JSON.stringify()) for objects containing Date, Map, or Set values (which the JSON round-trip destroys), and it handles transferable objects via the transfer option for zero-copy moves to Web Workers. For environments without structuredClone() (Node.js <17, IE), JSON.parse(JSON.stringify()) is the fastest option for plain JSON data. The manual deepClone above is useful when you need to add custom logic (e.g., skipping certain keys, transforming values during cloning).

Recursive Traversal and Transformation of Nested JSON

Recursive traversal visits every node in a nested JSON tree — objects, arrays, and leaf values. The pattern has two variants: read-only traversal (collecting paths, auditing values) and transforming traversal (returning a new tree with modified values). Transforming traversal is the basis for key renaming, value redaction, schema normalization, and type coercion across entire JSON documents.

// ── Read-only traversal: collect all leaf key-value pairs ─────────
function collectLeaves(node, path = "", results = []) {
  if (Array.isArray(node)) {
    node.forEach((item, i) => collectLeaves(item, `${path}[${i}]`, results));
  } else if (node !== null && typeof node === "object") {
    for (const [key, val] of Object.entries(node)) {
      collectLeaves(val, path ? `${path}.${key}` : key, results);
    }
  } else {
    results.push({ path, value: node });  // leaf
  }
  return results;
}

const doc = { user: { name: "Alice", scores: [90, 85] }, active: true };
collectLeaves(doc);
// [
//   { path: "user.name",       value: "Alice" },
//   { path: "user.scores[0]",  value: 90 },
//   { path: "user.scores[1]",  value: 85 },
//   { path: "active",          value: true },
// ]

// ── Transforming traversal: apply a mapper to every node ──────────
function mapJson(node, transform) {
  const mapped = transform(node);        // transform current node first
  if (Array.isArray(mapped)) {
    return mapped.map((item) => mapJson(item, transform));
  }
  if (mapped !== null && typeof mapped === "object") {
    return Object.fromEntries(
      Object.entries(mapped).map(([k, v]) => [k, mapJson(v, transform)])
    );
  }
  return mapped;  // primitive leaf — return as-is
}

// Example: redact all string values containing "password" in their key
// (walk keys, not values — need a key-aware version)
function mapJsonKeys(node, transform, parentKey = "") {
  if (Array.isArray(node)) {
    return node.map((item, i) => mapJsonKeys(item, transform, String(i)));
  }
  if (node !== null && typeof node === "object") {
    return Object.fromEntries(
      Object.entries(node).map(([k, v]) => {
        const newKey = transform(k, parentKey);
        return [newKey, mapJsonKeys(v, transform, k)];
      })
    );
  }
  return node;
}

// Rename all keys from snake_case to camelCase
const toCamel = (s) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
const camelDoc = mapJsonKeys(
  { user_name: "Alice", home_address: { zip_code: "10001" } },
  toCamel
);
// { userName: "Alice", homeAddress: { zipCode: "10001" } }

// ── Redact sensitive values ───────────────────────────────────────
const sensitiveKeys = new Set(["password", "token", "secret", "ssn"]);

function redactSensitive(node, key = "") {
  if (sensitiveKeys.has(key)) return "[REDACTED]";
  if (Array.isArray(node)) return node.map((item) => redactSensitive(item));
  if (node !== null && typeof node === "object") {
    return Object.fromEntries(
      Object.entries(node).map(([k, v]) => [k, redactSensitive(v, k)])
    );
  }
  return node;
}

redactSensitive({ user: "Alice", password: "secret123", token: "abc" });
// { user: "Alice", password: "[REDACTED]", token: "[REDACTED]" }

// ── Iterative traversal with stack (avoids call stack overflow) ───
function traverseIterative(root, visitor) {
  const stack = [{ node: root, path: "" }];
  while (stack.length) {
    const { node, path } = stack.pop();
    visitor(node, path);
    if (Array.isArray(node)) {
      node.forEach((item, i) => stack.push({ node: item, path: `${path}[${i}]` }));
    } else if (node !== null && typeof node === "object") {
      for (const [k, v] of Object.entries(node)) {
        stack.push({ node: v, path: path ? `${path}.${k}` : k });
      }
    }
  }
}

The iterative traversal with an explicit stack is essential for JSON from untrusted sources — a malicious payload with 10,000 nesting levels would exhaust the JavaScript call stack in the recursive version (typical V8 limit is ~10,000 frames). The iterative version processes the same payload using heap memory for the stack, which is orders of magnitude larger. For the transforming variant, iterative reconstruction is more complex (requires building results from leaves up), so the recursive approach is preferred when the maximum nesting depth is bounded and trusted. See our guide on JSON transform for more transformation patterns.

Cycle Detection in Circular JSON Structures

A circular reference exists when an object contains itself directly or through a chain of references — obj.self = obj. Valid JSON text cannot represent circular references (JSON.stringify rejects them), but live JavaScript objects can. Any recursive function without cycle detection will enter infinite recursion and crash with a stack overflow. Detect cycles using a Set of visited object references, and critically, remove each object after processing its children to allow diamond references (valid shared subtrees that are not cycles).

// ── Detect if a circular reference exists ────────────────────────
function hasCircular(node, seen = new Set()) {
  if (node === null || typeof node !== "object") return false;
  if (seen.has(node)) return true;      // cycle detected

  seen.add(node);
  const children = Array.isArray(node) ? node : Object.values(node);
  for (const child of children) {
    if (hasCircular(child, seen)) return true;
  }
  seen.delete(node);    // remove after children — allows diamond refs
  return false;
}

// ── Direct circular reference ─────────────────────────────────────
const obj = { name: "Alice" };
obj.self = obj;
console.log(hasCircular(obj));   // true

// ── Indirect circular reference (chain) ───────────────────────────
const a = { name: "A" };
const b = { name: "B", ref: a };
a.ref = b;              // a → b → a — cycle via two hops
console.log(hasCircular(a));   // true

// ── Diamond reference (NOT a cycle) ──────────────────────────────
const shared = { value: 42 };
const diamond = { left: shared, right: shared };  // same object, two paths
console.log(hasCircular(diamond));   // false — seen.delete() allows this

// ── Find the path where a cycle occurs ───────────────────────────
function findCircularPath(node, path = "root", seen = new Map()) {
  if (node === null || typeof node !== "object") return null;
  if (seen.has(node)) {
    return `Cycle: ${path} → ${seen.get(node)}`;
  }
  seen.set(node, path);
  const entries = Array.isArray(node)
    ? node.map((v, i) => [String(i), v])
    : Object.entries(node);
  for (const [key, val] of entries) {
    const result = findCircularPath(val, `${path}.${key}`, seen);
    if (result) return result;
  }
  seen.delete(node);
  return null;
}

const circular2 = { a: { b: {} } };
circular2.a.b.back = circular2.a;
console.log(findCircularPath(circular2));
// "Cycle: root.a.b.back → root.a"

// ── JSON.stringify with circular reference handling ───────────────
function stringifyWithCircular(obj) {
  const seen = new Set();
  return JSON.stringify(obj, (key, value) => {
    if (typeof value === "object" && value !== null) {
      if (seen.has(value)) return "[Circular]";
      seen.add(value);
    }
    return value;
  });
}

const c = { name: "Alice" };
c.self = c;
console.log(stringifyWithCircular(c));
// '{"name":"Alice","self":"[Circular]"}'

// ── WeakSet alternative — allows GC of visited objects ───────────
// WeakSet cannot be iterated but works for cycle detection:
function hasCircularWeak(node, seen = new WeakSet()) {
  if (node === null || typeof node !== "object") return false;
  if (seen.has(node)) return true;
  seen.add(node);       // WeakSet — no need to delete (GC handles it)
  // NOTE: WeakSet does not support delete in the same way — cannot do
  // diamond-safe detection with WeakSet (no seen.delete)
  // Use Set for diamond-safe detection; WeakSet only for pure cycle check
  return Object.values(node).some((v) => hasCircularWeak(v, seen));
}

The distinction between a Set and a WeakSet matters here: a WeakSet does not support delete() in a meaningful traversal-safe way, so you cannot implement the diamond-reference-safe pattern with it. Use a plain Set for cycle detection when you need to correctly handle diamond references. The JSON.stringify replacer approach is the most practical way to serialize objects that may contain circular references — replace back-edges with a "[Circular]" sentinel rather than throwing.

TypeScript Interfaces for Deeply Nested JSON

TypeScript interfaces model nested JSON structure with zero runtime overhead — they are erased at compile time. The key benefit is that TypeScript's type checker enforces correct access patterns at every nesting level, catching missing-key bugs, wrong-type assignments, and missing null checks before the code runs. Combine interfaces with strict null checks and optional chaining for maximum safety.

// ── Basic nested interfaces ───────────────────────────────────────
interface Geo {
  lat: number;
  lng: number;
}

interface Address {
  street: string;
  city: string;
  zip: string;
  geo?: Geo;          // optional nested object
}

interface User {
  id: number;
  name: string;
  email: string;
  address: Address;
  tags: string[];
  metadata?: Record<string, unknown>;  // unknown shape
}

interface ApiResponse {
  data: User[];
  total: number;
  page: number;
}

// ── Accessing typed nested JSON ───────────────────────────────────
declare const response: ApiResponse;

const city = response.data[0]?.address.city;
// TypeScript infers: string | undefined (because data[0] may be undefined)

const lat = response.data[0]?.address.geo?.lat;
// TypeScript infers: number | undefined

// ── Recursive / self-referential types ───────────────────────────
interface TreeNode {
  id: number;
  label: string;
  children?: TreeNode[];   // same type — recursive interface
}

interface CommentThread {
  id: number;
  text: string;
  author: string;
  replies?: CommentThread[];
}

// ── Utility types for partial updates ────────────────────────────
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

function mergeDeep<T>(base: T, overrides: DeepPartial<T>): T {
  const result = { ...base };
  for (const key of Object.keys(overrides) as Array<keyof T>) {
    const override = overrides[key];
    if (override !== null && typeof override === "object" && !Array.isArray(override)) {
      result[key] = mergeDeep(base[key] as object, override as object) as T[keyof T];
    } else if (override !== undefined) {
      result[key] = override as T[keyof T];
    }
  }
  return result;
}

// ── Runtime validation with zod ───────────────────────────────────
// npm install zod
import { z } from "zod";

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zip: z.string(),
  geo: z.object({ lat: z.number(), lng: z.number() }).optional(),
});

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  address: AddressSchema,
  tags: z.array(z.string()),
});

// Parse and validate — throws ZodError on mismatch
const raw = JSON.parse('{"id":1,"name":"Alice","email":"a@b.com","address":{"street":"1 Main","city":"NYC","zip":"10001"},"tags":["admin"]}');
const user: z.infer<typeof UserSchema> = UserSchema.parse(raw);
// user is fully typed — TypeScript knows user.address.city is string

// ── Type guards for runtime narrowing ────────────────────────────
function isAddress(val: unknown): val is Address {
  return (
    val !== null &&
    typeof val === "object" &&
    "city" in val &&
    typeof (val as Address).city === "string"
  );
}

declare const maybeAddress: unknown;
if (isAddress(maybeAddress)) {
  console.log(maybeAddress.city);   // TypeScript: string
}

Zod is the recommended approach when parsing JSON from external sources — it combines runtime validation with TypeScript type inference, so a single schema definition provides both. The z.infer<typeof Schema> pattern derives the TypeScript type from the Zod schema, eliminating the need to maintain parallel interface and schema definitions. For deeply nested schemas, compose Zod objects (z.object()) the same way you compose TypeScript interfaces. See our TypeScript JSON types guide for advanced patterns including discriminated unions and branded types.

Flattening and Querying Nested JSON with jq and JSONPath

Beyond JavaScript and TypeScript, two purpose-built tools excel at querying and transforming nested JSON: jq (a command-line JSON processor) and JSONPath (an XPath-like query language for JSON). Both avoid the need to write traversal code for one-off queries and data extraction tasks. JavaScript has the JSON flatten pattern for converting nested structures to single-level objects, covered in depth in our flattening guide.

# ── jq: command-line JSON processor ──────────────────────────────
# Install: brew install jq  (macOS) / apt install jq  (Debian/Ubuntu)

# Sample data in data.json:
# { "users": [ { "name": "Alice", "address": { "city": "NYC" } },
#               { "name": "Bob",   "address": { "city": "LA"  } } ] }

# Extract a deeply nested field from all array items
jq '.users[].address.city' data.json
# "NYC"
# "LA"

# Optional field access — returns null if field missing (no error)
jq '.users[].address.zip? // "unknown"' data.json

# Flatten nested to dot-notation keys (jq built-in: [leaf_paths])
jq '[leaf_paths as $p | { ([$p[] | tostring] | join(".")): getpath($p) }] | add' data.json

# Filter: only users in NYC
jq '.users[] | select(.address.city == "NYC")' data.json

# Map: extract name and city into a flat object
jq '.users[] | { name, city: .address.city }' data.json
# { "name": "Alice", "city": "NYC" }
# { "name": "Bob",   "city": "LA"  }

# Recursive descent: find all values with key "city" at any depth
jq '.. | objects | .city? // empty' data.json

# ── JSONPath: query language for JSON ─────────────────────────────
# JSONPath is supported in many languages via libraries

# JavaScript — npm install jsonpath-plus
import { JSONPath } from "jsonpath-plus";

const data = {
  store: {
    books: [
      { title: "JSON Guide", price: 9.99, inStock: true },
      { title: "TypeScript Deep Dive", price: 14.99, inStock: false },
    ],
  },
};

// Select all book titles
JSONPath({ path: "$.store.books[*].title", json: data });
// ["JSON Guide", "TypeScript Deep Dive"]

// Select books in stock
JSONPath({ path: "$.store.books[?(@.inStock==true)].title", json: data });
// ["JSON Guide"]

// Select all prices anywhere in the document (recursive descent)
JSONPath({ path: "$..price", json: data });
// [9.99, 14.99]

// ── JavaScript: Object.keys recursive key finder ─────────────────
function findByKey(obj, targetKey, results = []) {
  if (typeof obj !== "object" || obj === null) return results;
  for (const [key, val] of Object.entries(obj)) {
    if (key === targetKey) results.push(val);
    findByKey(val, targetKey, results);
  }
  return results;
}

findByKey(data, "title");
// ["JSON Guide", "TypeScript Deep Dive"]

jq's recursive descent operator (..) is the fastest way to extract values at arbitrary depth from the command line — no code required. Use jq for one-off queries, shell scripts, and CI pipelines that process JSON. JSONPath is better suited for application code where queries are constructed dynamically or come from configuration. For structured flattening with delimiter control and language integration, JavaScript's flat package and Python's pandas json_normalize() remain the most practical choices. See also our guide on JSON.parse() for parsing fundamentals and our TypeScript JSON types guide for typing patterns.

Key Terms

nested object
A JSON object whose values include other JSON objects or arrays, forming a hierarchical tree structure. Any JSON value that is itself a { } object or [] array creates a nesting level. Nesting depth is theoretically unlimited in the JSON specification, but parsers may impose practical limits (Node.js's JSON.parse() handles hundreds of levels before hitting call stack limits). Nested objects model real-world hierarchies naturally — a user has an address, an address has a city, a city has coordinates. The trade-off is that accessing deep properties requires traversing every intermediate level, making access patterns more complex than flat key-value structures.
optional chaining
The ?. operator introduced in ES2020 (ECMAScript 2020) that safely accesses properties of a value that may be null or undefined. If the left-hand side evaluates to null or undefined, the entire expression short-circuits and returns undefined instead of throwing a TypeError. Three syntactic forms: property access (obj?.key), bracket notation (obj?.["key"]), and method call (obj?.method()). Supported in Node.js 14+, TypeScript 3.7+, and all modern browsers. The operator is particularly valuable for accessing deeply nested JSON from APIs where any field may be absent. TypeScript infers the return type as T | undefined when ?. is used, enforcing null handling at compile time.
nullish coalescing
The ?? operator introduced in ES2020 that returns the right-hand side operand when the left-hand side is null or undefined, and returns the left-hand side otherwise. Unlike the logical OR operator (||), nullish coalescing does not treat false, 0, or "" (empty string) as triggering the fallback — only true nullish values do. This makes it correct for JSON defaults where 0 and false are valid data values that must not be replaced. Commonly chained with optional chaining: const score = user?.stats?.score ?? 0. Available in Node.js 14+, TypeScript 3.7+, and modern browsers.
deep clone
A copy of an object where every nested object and array is also copied — the clone and the original share no references. Modifying any property of a deep clone, at any nesting depth, does not affect the original. Methods: structuredClone(obj) (built-in, Node.js 17+); JSON.parse(JSON.stringify(obj)) (works for plain JSON, strips undefined and functions, converts Date to strings); _.cloneDeep(obj) (lodash, handles circular references and more types). Contrast with shallow copy (spread { ...obj } or Object.assign()), which copies only the top-level properties — nested objects remain shared references.
shallow clone
A copy of an object where only the top-level properties are duplicated — nested objects and arrays still share the same references as the original. Created with spread syntax ({ ...obj }), Object.assign(, obj), or Array.prototype.slice() for arrays. Mutating a nested property of the shallow clone also mutates the original: const copy = { ...obj }; copy.address.city = "X" changes obj.address.city too, because copy.address and obj.address are the same object. Shallow copies are sufficient for immutable update patterns (React/Redux) when you spread at every changed level — only the objects on the mutation path need new identities.
recursive traversal
A depth-first walk of a nested JSON tree where a function calls itself for each nested object or array it encounters. The recursion bottoms out at leaf values (primitives and nulls). The standard pattern: check if the current node is an array (recurse into each element), a plain object (recurse into each value), or a primitive (process it). Recursive traversal is elegant but may hit JavaScript's call stack limit (~10,000 frames in V8) for pathologically deep structures. For untrusted input, convert to an iterative implementation using an explicit stack on the heap, which is bounded only by available memory. Recursive traversal is the foundation of JSON flatten, deep clone, schema validation, and key renaming operations.
circular reference
A data structure where an object's property chain eventually leads back to the object itself — creating a cycle. The simplest case is a direct self-reference: obj.self = obj. Indirect cycles involve chains: a.child = b; b.parent = a. JSON text cannot represent circular references — JSON.stringify() throws TypeError: Converting circular structure to JSON. Circular references exist only in live JavaScript (or Python) objects. Recursive functions without cycle detection enter infinite recursion and crash with a stack overflow. Detection requires tracking visited object references in a Set: check before recursing, add on entry, remove on exit (the exit removal is essential to allow diamond references — the same object reachable via two paths, which is valid and must not be mistaken for a cycle).

FAQ

How do I access deeply nested JSON properties without errors?

Use optional chaining (?.) introduced in ES2020. The expression obj?.a?.b?.c returns undefined instead of throwing TypeError: Cannot read properties of undefined when any intermediate key is missing or null. Combine with nullish coalescing for defaults: const city = user?.address?.city ?? "Unknown". Use ?? not || — the OR operator wrongly replaces 0 and "" (valid JSON values) with the fallback. For bracket notation with dynamic keys: obj?.["dynamic-key"]. Optional chaining is available in Node.js 14+, TypeScript 3.7+, Chrome 80+, Firefox 74+, and Safari 13.1+. For older environments, use lodash _.get(obj, "a.b.c", defaultValue), which performs the same null-guarded traversal with a path string. TypeScript with strictNullChecks infers the result type as T | undefined when ?. is used, enforcing explicit handling of the missing-value case at compile time.

What is the difference between shallow copy and deep copy for nested JSON?

A shallow copy duplicates only the top-level object — nested objects are shared by reference. Spread ({ ...obj }) and Object.assign(, obj) produce shallow copies. Mutating copy.address.city also mutates original.address.city because both point to the same address object in memory. A deep copy creates fully independent copies of every nested object down to primitive leaf values — no references are shared. Methods: structuredClone(obj) (Node.js 17+, modern browsers — handles Date, Map, Set); JSON.parse(JSON.stringify(obj)) (works for plain JSON, converts Date to strings and strips undefined); _.cloneDeep(obj) (lodash — handles circular references). For React and Redux state updates, shallow copy at each changed level (nested spread) is the standard pattern — you do not need a full deep clone when you spread every object on the path to the mutation.

How do I update a deeply nested property in JSON immutably?

Spread each object from the root to the changed property, replacing only the objects on the mutation path. To change state.user.address.city: const next = { ...state, user: { ...state.user, address: { ...state.user.address, city: "Boston" } } }. This creates new object identities at every level on the changed path while sharing all unchanged subtrees — React and Redux use reference equality to detect changes, so only affected components re-render. For paths known at runtime, use a helper: function setIn(obj, [head, ...tail], val) { return tail.length === 0 ? { ...obj, [head]: val } : { ...obj, [head]: setIn(obj[head] ?? {}, tail, val) }; }. For deeply nested updates (5+ levels), use immer (npm install immer) — write mutable-looking code inside a produce() call and immer produces an immutable structurally-shared result using Proxy. Redux Toolkit's createSlice uses immer internally.

How do I recursively traverse all keys in a nested JSON object?

Write a recursive function that handles three cases: array (recurse into each element), plain object (recurse into each value via Object.entries()), and primitive leaf (process or collect it). JavaScript: function walk(node, path = "") { if (Array.isArray(node)) { node.forEach((item, i) => walk(item, `${path}[${i}]`)); } else if (node !== null && typeof node === "object") { for (const [k, v] of Object.entries(node)) { walk(v, path ? `${path}.${k}` : k); } } else { console.log(path, node); } }. To transform (rather than just read), return a new value at each level: replace primitive leaves with transformed values and reconstruct objects and arrays with Object.fromEntries() and Array.map(). For deep trees from untrusted input, use an iterative implementation with an explicit stack to avoid call stack overflow — push child nodes onto a const stack = [] array and while (stack.length) loop instead of recursing.

How do I detect circular references in a JSON object?

Track visited objects in a Set and check before recursing. If the current object is already in the Set, a cycle is detected. After processing an object's children, remove it from the Set — this critical step allows diamond references (the same object reachable via two paths) which are valid and must not trigger a false positive. JavaScript: function hasCircular(node, seen = new Set()) { if (node === null || typeof node !== "object") return false; if (seen.has(node)) return true; seen.add(node); const children = Array.isArray(node) ? node : Object.values(node); for (const child of children) { if (hasCircular(child, seen)) return true; } seen.delete(node); return false; }. To serialize objects with circular references instead of throwing, use a JSON.stringify replacer: const seen = new Set(); JSON.stringify(obj, (k, v) => { if (typeof v === "object" && v !== null) { if (seen.has(v)) return "[Circular]"; seen.add(v); } return v; }).

How do I type deeply nested JSON in TypeScript?

Define interfaces that mirror the JSON hierarchy — each nested object becomes its own interface. Compose them: interface Address { city: string; zip: string; geo?: { lat: number; lng: number }; } then interface User { name: string; address: Address; tags: string[]; }. Optional nested fields use ?: — TypeScript's strictNullChecks then requires explicit null handling before use. For recursive structures (trees, comment threads) use self-referential interfaces: interface TreeNode { id: number; children?: TreeNode[]; }. For runtime validation that parsed JSON matches the expected type, use zod: define a schema with z.object(), call Schema.parse(JSON.parse(raw)), and derive the TypeScript type with z.infer<typeof Schema>. For unknown-shape JSON, use Record<string, unknown> instead of any — this forces type narrowing before use, preventing silent runtime errors. See our TypeScript JSON types guide for advanced patterns.

What is optional chaining and how does it work with JSON?

Optional chaining (?.) is an ES2020 operator that returns undefined when the left-hand operand is null or undefined, instead of throwing a TypeError. For JSON access: obj?.a?.b?.c evaluates obj.a — if null or undefined, the rest of the chain is skipped and undefined is returned. This is essential for deeply nested JSON from APIs where any intermediate key may be absent. Three syntactic forms: property access (obj?.key), bracket notation for dynamic keys (obj?.["key"]), and method calls (obj?.method()). Combine with nullish coalescing for defaults: const zip = user?.address?.zip ?? "00000". Available in Node.js 14+, TypeScript 3.7+, Chrome 80+, Firefox 74+, Safari 13.1+. TypeScript infers the return type as T | undefined, enforcing null handling at compile time when strict mode is enabled. For pre-ES2020 environments, the equivalent is obj && obj.a && obj.a.b && obj.a.b.c — correct for JSON (no falsy non-nullish objects) but verbose.

How do I flatten a nested JSON object to a single level?

Write a recursive function that joins keys with a dot delimiter as it descends: function flatten(obj, prefix = "", result = { }) { for (const [k, v] of Object.entries(obj)) { const key = prefix ? prefix + "." + k : k; if (v !== null && typeof v === "object" && !Array.isArray(v)) { flatten(v, key, result); } else { result[key] = v; } } return result; }. Result: flatten({ a: { b: { c: 1 } } }) { "a.b.c": 1 }. For production use, the flat npm package (npm install flat) handles edge cases — prototype pollution prevention, custom delimiters, safe mode to preserve arrays, and max depth limiting. Python: pandas pd.json_normalize(data, sep=".") flattens to a DataFrame for immediate CSV export or analysis. The inverse (reconstructing nested from flat) is called unflattening — split each dot-key and create intermediate objects. See our full JSON flatten guide for array strategies, unflatten patterns, CSV export, and Elasticsearch indexing.

Further reading and primary sources

  • MDN: Optional chaining (?.)MDN reference for the ?. operator — syntax, browser support, and examples for all three forms
  • MDN: structuredClone()MDN reference for the built-in deep clone function — supported types, transfer option, and browser compatibility
  • MDN: Nullish coalescing (??)MDN reference for the ?? operator — how it differs from || and correct use with JSON values
  • Immer documentationOfficial Immer docs — immutable state updates using Proxy-based mutable draft syntax
  • Zod documentationOfficial Zod docs — TypeScript-first schema validation with inferred types for JSON parsing