JSON Flattening Algorithm: From Nested Objects to Dot-Notation Keys#

Dealing with deeply nested JSON data is a common pain point in backend development. APIs often return data nested four or five levels deep, requiring a chain of property access: response.data.user.profile.address.city. A JSON flattening tool converts nested structures into single-level objects, making data access more direct.

The Essence of Flattening#

Transforming { "a": { "b": { "c": 1 } } } into { "a.b.c": 1 } — that’s flattening. The core is recursively traversing the object tree and concatenating paths with a delimiter.

function flatten(obj: any, delimiter = '.', prefix = '', result: Record<string, any> = {}): Record<string, any> {
  // Edge case: non-object or array, assign directly
  if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
    result[prefix || 'root'] = obj
    return result
  }

  // Iterate through object properties
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      const newKey = prefix ? `${prefix}${delimiter}${key}` : key
      const value = obj[key]
      
      // Nested object: recurse
      if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
        flatten(value, delimiter, newKey, result)
      } else {
        // Leaf node: assign directly
        result[newKey] = value
      }
    }
  }
  
  return result
}

Key insight: The result object is passed as a parameter to avoid creating new objects on each recursion. prefix tracks the current path.

Unflattening: Rebuilding Nested Structure from Dot-Notation Keys#

The reverse operation is more complex: transforming "a.b.c": 1 back into { a: { b: { c: 1 } } }. This requires building objects layer by layer.

function unflatten(obj: Record<string, any>, delimiter = '.'): any {
  const result: any = {}
  
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      const keys = key.split(delimiter)  // "a.b.c" → ["a", "b", "c"]
      let current = result
      
      // Create nested objects layer by layer (except the last)
      for (let i = 0; i < keys.length - 1; i++) {
        const k = keys[i]
        if (!(k in current)) {
          current[k] = {}
        }
        current = current[k]
      }
      
      // Assign to the leaf node
      current[keys[keys.length - 1]] = obj[key]
    }
  }
  
  return result
}

Here, a current pointer traverses deeper, creating empty objects for missing keys. The value is assigned at the leaf node.

Handling Edge Cases#

1. What About Arrays?#

The implementation above treats arrays as plain values. To preserve array structure:

if (Array.isArray(value)) {
  // Option 1: Use index as key
  value.forEach((item, index) => {
    const arrayKey = `${newKey}[${index}]`
    if (typeof item === 'object' && item !== null) {
      flatten(item, delimiter, arrayKey, result)
    } else {
      result[arrayKey] = item
    }
  })
}

2. Keys Containing the Delimiter#

If the original key already contains ., it creates ambiguity. Solutions:

  • Use an uncommon delimiter (like / or |)
  • Escape the delimiter: "a\.b" → actual key is a.b
  • Use special format: {"a.b": 1} flattens to {"['a.b']": 1}
const newKey = prefix ? `${prefix}${delimiter}${JSON.stringify(key)}` : key
// Output: {"a.b": 1} → {'"a"["b"]': 1}  or wrap in quotes

3. Empty Objects and Null#

const test1 = { a: {} }     // After flatten: {} (empty object produces no keys)
const test2 = { a: null }   // After flatten: {"a": null}
const test3 = { a: [] }     // After flatten: {"a": []}

Empty objects have no properties to iterate, so no keys are produced. null and [] are valid values and are preserved.

Practical Use Cases#

1. Form Data Processing#

Complex forms often use nested objects:

{
  "user": {
    "name": "John",
    "contact": {
      "email": "john@example.com",
      "phone": "+1234567890"
    }
  }
}

After flattening, you can access directly with formData["user.contact.email"], making it easier for validation libraries like Yup to process.

2. Database Storage#

MongoDB supports nested documents, but relational databases require flattening. After flattening JSON, each key corresponds to a column:

CREATE TABLE user_data (
  user_name VARCHAR(100),
  user_contact_email VARCHAR(100),
  user_contact_phone VARCHAR(20)
);

3. Configuration File Merging#

Multiple config files can be distinguished by prefix:

// config1.json
{ "database": { "host": "localhost" } }

// config2.json  
{ "database": { "port": 3306 } }

Flatten, merge, then unflatten:

const merged = { ...flatten(config1), ...flatten(config2) }
const final = unflatten(merged)
// { database: { host: "localhost", port: 3306 } }

Performance Considerations#

Time Complexity#

Both flattening and unflattening are O(n), where n is the number of leaf nodes.

Depth Limit#

JavaScript recursion has a call stack limit (typically around 10,000 levels). For extremely deep nested objects, use iteration instead:

function flattenIterative(obj: any, delimiter = '.'): Record<string, any> {
  const result: Record<string, any> = {}
  const stack: Array<{ node: any, prefix: string }> = [{ node: obj, prefix: '' }]
  
  while (stack.length > 0) {
    const { node, prefix } = stack.pop()!
    
    for (const key in node) {
      if (node.hasOwnProperty(key)) {
        const newKey = prefix ? `${prefix}${delimiter}${key}` : key
        const value = node[key]
        
        if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
          stack.push({ node: value, prefix: newKey })
        } else {
          result[newKey] = value
        }
      }
    }
  }
  
  return result
}

Using a stack to simulate recursion avoids call stack overflow.

Online Tool#

Based on the implementation above, I built an online JSON flattening tool: JSON Flatten

Features:

  • Bidirectional conversion (flatten / unflatten)
  • Custom delimiter (default: .)
  • Supports nested objects, arrays, and null
  • Real-time error feedback

Flattening is a small feature, but handling all edge cases properly takes effort. Hope this helps.


Related tools: JSON Formatter | JSON Diff