receta
Object

Core Operations

The core operations provide fundamental transformations for restructuring objects. These 5 functions handle the most common scenarios: flattening hierarchies, renaming keys, filtering fields, and deep merging.

Table of Contents

  1. flatten
  2. unflatten
  3. rename
  4. mask
  5. deepMerge
  6. Comparison Table
  7. When to Use Which

flatten

Converts nested objects into flat objects with dot-notation keys.

Signature

Signature
function flatten(obj: PlainObject, options?: FlattenOptions): FlatObject

interface FlattenOptions {
  separator?: string        // Default: '.'
  maxDepth?: number        // Default: Infinity
  flattenArrays?: boolean  // Default: false
}

Real-World: Database Query Parameters

// ElasticSearch-style query builder
const searchQuery = {
  query: {
    bool: {
      must: [
        { match: { title: 'receta' } }
      ],
      filter: {
        range: {
          date: { gte: '2024-01-01' }
        }
      }
    }
  }
}

// Flatten for logging/debugging. Arrays stay whole unless you set flattenArrays.
const flat = flatten(searchQuery)
// {
//   'query.bool.must': [{ match: { title: 'receta' } }],
//   'query.bool.filter.range.date.gte': '2024-01-01'
// }

Real-World: Form Data to URL Params

// Next.js search params
const filters = {
  category: 'electronics',
  price: {
    min: 100,
    max: 500
  },
  features: {
    wireless: true,
    waterproof: false
  }
}

const params = new URLSearchParams(
  mapValues(flatten(filters, { separator: '_' }), String)
)
// category=electronics&price_min=100&price_max=500&features_wireless=true...

Options Examples

const nested = { user: { profile: { name: 'Alice', age: 30 } } }

// Custom separator
flatten(nested, { separator: '_' })
// => { user_profile_name: 'Alice', user_profile_age: 30 }

// Max depth
flatten(nested, { maxDepth: 1 })
// => { 'user.profile': { name: 'Alice', age: 30 } }

// Flatten arrays
const data = { items: ['a', 'b', 'c'] }
flatten(data, { flattenArrays: true })
// => { 'items.0': 'a', 'items.1': 'b', 'items.2': 'c' }

In Pipelines

flatten() and unflatten() work in a pipe with no arguments. With options, wrap them in an arrow function, because TypeScript reads flatten({ separator: '__' }) as the data-first call.

import { pipe } from 'remeda'

const config = { database: { host: 'localhost', port: '5432' } }

// Prepare nested config for environment variables
const envVars = pipe(
  config,
  (c) => flatten(c, { separator: '__' }),
  mapKeys((key) => key.toUpperCase())
)
// => { DATABASE__HOST: 'localhost', DATABASE__PORT: '5432' }

unflatten

Converts flat objects with dot-notation keys back into nested structures.

Signature

Signature
function unflatten(obj: FlatObject, options?: UnflattenOptions): PlainObject

interface UnflattenOptions {
  separator?: string  // Default: '.'
}

Real-World: Environment Variables to Config

// Load environment variables
const env = {
  'DATABASE_HOST': 'localhost',
  'DATABASE_PORT': '5432',
  'API_KEY': 'secret',
  'API_TIMEOUT': '30000'
}

// Convert to nested config
const config = pipe(
  env,
  mapKeys((key) => key.toLowerCase()),
  (flat) => unflatten(flat, { separator: '_' })
)
// {
//   database: { host: 'localhost', port: '5432' },
//   api: { key: 'secret', timeout: '30000' }
// }

Real-World: Redis Hash to Object

// Redis stores flat key-value pairs
const redisHash = {
  'user:123:name': 'Alice',
  'user:123:email': 'alice@example.com',
  'user:123:settings:theme': 'dark',
  'user:123:settings:notifications': 'true'
}

const user = unflatten(
  removePrefix(redisHash, 'user:123:'),
  { separator: ':' }
)
// {
//   name: 'Alice',
//   email: 'alice@example.com',
//   settings: { theme: 'dark', notifications: 'true' }
// }

Array Reconstruction

// Unflatten reconstructs arrays from numeric keys
const flat = {
  'items.0': 'apple',
  'items.1': 'banana',
  'items.2.name': 'orange',
  'items.2.color': 'orange'
}

unflatten(flat)
// {
//   items: [
//     'apple',
//     'banana',
//     { name: 'orange', color: 'orange' }
//   ]
// }

Round-Trip

unflatten restores what flatten produced, as long as no key contains the separator. A key like 'a.b' comes back nested.

const original = { a: { b: { c: 1 } } }
const restored = unflatten(flatten(original))
// => { a: { b: { c: 1 } } }

rename

Renames object keys according to a mapping, preserving unmapped keys.

Signature

Signature
function rename<T extends PlainObject>(obj: T, mapping: Record<string, string>): PlainObject

Real-World: API Response Normalization

// GitHub API → Your schema
const githubUser = {
  login: 'alice',
  avatar_url: 'https://github.com/alice.png',
  public_repos: 42,
  created_at: '2015-01-01T00:00:00Z'
}

const normalized = rename(githubUser, {
  login: 'username',
  avatar_url: 'avatarUrl',
  public_repos: 'repoCount',
  created_at: 'joinedAt'
})
// {
//   username: 'alice',
//   avatarUrl: 'https://github.com/alice.png',
//   repoCount: 42,
//   joinedAt: '2015-01-01T00:00:00Z'
// }

Real-World: Database Schema Migration

// Rename columns after schema change
const oldRecord = {
  user_id: 123,
  first_name: 'Alice',
  last_name: 'Smith',
  email_address: 'alice@example.com'
}

const newRecord = rename(oldRecord, {
  user_id: 'id',
  first_name: 'firstName',
  last_name: 'lastName',
  email_address: 'email'
})

Partial Mapping

// Only rename specific keys, keep others unchanged
const data = { id: 1, oldName: 'Alice', age: 30, city: 'NYC' }

rename(data, { oldName: 'name' })
// { id: 1, name: 'Alice', age: 30, city: 'NYC' }
//   ↑ Unmapped keys preserved

In Pipelines with Multiple APIs

// Normalize responses from different APIs
const normalizeStripeCustomer = (customer: PlainObject) =>
  pipe(
    customer,
    rename({
      id: 'customerId',
      email: 'email',
      created: 'createdAt',
      metadata: 'meta'
    }),
    validateShape(customerSchema)
  )

mask

Security-focused filtering that keeps only explicitly allowed keys (allowlist approach).

Signature

Signature
function mask<T extends PlainObject>(obj: T, allowedKeys: readonly string[]): Partial<T>

Real-World: Sanitize Before Logging

// Remove sensitive data before logging
const request = {
  userId: 123,
  email: 'alice@example.com',
  password: 'hunter2',
  creditCard: '4111-1111-1111-1111',
  apiKey: 'sk_live_...',
  timestamp: 1234567890
}

const sanitized = mask(request, ['userId', 'email', 'timestamp'])
logger.info('User request', sanitized)
// Logs: { userId: 123, email: 'alice@example.com', timestamp: 1234567890 }

Real-World: API Response Filtering

// Only send necessary fields to client
const dbUser = {
  id: 1,
  username: 'alice',
  email: 'alice@example.com',
  passwordHash: '$2b$10$...',
  salt: 'random_salt',
  internalNotes: 'VIP customer',
  createdAt: new Date(),
  updatedAt: new Date()
}

const publicUser = mask(dbUser, ['id', 'username', 'email', 'createdAt'])
// Only safe fields exposed to client

Real-World: GDPR Data Minimization

// Only collect necessary analytics data
const userAction = {
  userId: 123,
  fullName: 'Alice Smith',
  email: 'alice@example.com',
  ipAddress: '192.168.1.1',
  action: 'purchase',
  amount: 99.99,
  timestamp: Date.now()
}

// GDPR: collect minimal data
const analytics = mask(userAction, ['action', 'amount', 'timestamp'])
// No PII stored in analytics

mask vs Remeda.pick

// Same behavior, different semantic intent

// Remeda.pick: "I want these properties"
const selected = R.pick(obj, ['id', 'name'])

// Object.mask: "I only allow these properties (security)"
const safe = mask(obj, ['id', 'name'])

// Use mask when security/privacy is the concern

In Pipelines

// Complete sanitization pipeline
const sanitizeUser = (dbUser: DbUser) =>
  pipe(
    dbUser,
    mask(['id', 'username', 'email', 'avatar', 'role']),
    stripUndefined,
    rename({ username: 'displayName' })
  )

deepMerge

Deep merge multiple objects with configurable array handling strategies.

Signature

Signature
function deepMerge(
  objects: readonly PlainObject[],
  options?: DeepMergeOptions
): PlainObject

interface DeepMergeOptions {
  arrayStrategy?: 'replace' | 'concat' | 'merge'  // Default: 'replace'
  customMerge?: (key: string, target: unknown, source: unknown) => unknown
}

Real-World: Configuration Merging (Next.js Style)

// next.config.js pattern
const defaultConfig = {
  images: {
    domains: ['example.com'],
    deviceSizes: [640, 750, 828, 1080]
  },
  webpack: { /* defaults */ },
  env: {
    API_URL: 'https://api.example.com'
  }
}

const userConfig = {
  images: {
    domains: ['cdn.example.com'],  // Should merge, not replace
    formats: ['image/avif', 'image/webp']
  },
  env: {
    API_URL: 'https://prod.example.com',  // Override
    DEBUG: 'true'
  }
}

const finalConfig = deepMerge([defaultConfig, userConfig])
// {
//   images: {
//     domains: ['cdn.example.com'],  // Replaced (default strategy)
//     deviceSizes: [640, 750, 828, 1080],  // Preserved
//     formats: ['image/avif', 'image/webp']  // Added
//   },
//   webpack: { /* defaults */ },
//   env: {
//     API_URL: 'https://prod.example.com',
//     DEBUG: 'true'
//   }
// }

Array Strategy: replace (default)

const obj1 = { tags: ['javascript', 'typescript'] }
const obj2 = { tags: ['react'] }

// Source replaces target
deepMerge([obj1, obj2])
// => { tags: ['react'] }

Array Strategy: concat

const obj1 = { tags: ['javascript', 'typescript'] }
const obj2 = { tags: ['react'] }

// Useful for accumulating arrays
deepMerge([obj1, obj2], { arrayStrategy: 'concat' })
// => { tags: ['javascript', 'typescript', 'react'] }

Array Strategy: merge

Merges arrays by index. Objects and arrays merge, and anything else takes the source value. The result is as long as the source array, so target items past its end are dropped.

const defaults = {
  plugins: [
    { name: 'plugin-a', enabled: true },
    { name: 'plugin-b', enabled: true }
  ]
}

const overrides = {
  plugins: [
    { enabled: false }  // Disable first plugin
  ]
}

// plugin-b is gone: the source array has one item
deepMerge([defaults, overrides], { arrayStrategy: 'merge' })
// => { plugins: [{ name: 'plugin-a', enabled: false }] }

Custom Merge Function

// Advanced: custom merge logic per key
const obj1 = { count: 5, views: 10 }
const obj2 = { count: 3, views: 20 }

deepMerge([obj1, obj2], {
  customMerge: (key, target, source) => {
    // Add counts instead of replacing
    if (key === 'count') {
      return (target as number) + (source as number)
    }
    // Max for views
    if (key === 'views') {
      return Math.max(target as number, source as number)
    }
    return source  // Default: source wins
  }
})
// => { count: 8, views: 20 }

Real-World: Merge User Preferences Across Devices

const desktopPrefs = {
  theme: 'dark',
  editor: {
    fontSize: 14,
    tabSize: 2,
    fontFamily: 'Monaco'
  },
  shortcuts: ['cmd+s', 'cmd+b']
}

const mobilePrefs = {
  theme: 'light',  // Override
  editor: {
    fontSize: 16,  // Override
    lineNumbers: true  // Add
  },
  shortcuts: ['tap-save']  // Replace
}

const merged = deepMerge([desktopPrefs, mobilePrefs])
// {
//   theme: 'light',
//   editor: {
//     fontSize: 16,
//     tabSize: 2,
//     fontFamily: 'Monaco',
//     lineNumbers: true
//   },
//   shortcuts: ['tap-save']
// }

Merging Multiple Objects

// Merge environment-specific configs
const base = { /* base config */ }
const development = { /* dev overrides */ }
const local = { /* local overrides */ }

// Later configs override earlier ones
const config = deepMerge([base, development, local])

Comparison Table

FunctionInputOutputUse Case
flattenNested objectFlat objectDatabase queries, URL params, logging
unflattenFlat objectNested objectEnv vars → config, Redis → object
renameObject + mappingRenamed objectAPI normalization, schema migration
maskObject + allowlistFiltered objectSecurity, logging, GDPR compliance
deepMergeArray of objectsMerged objectConfig files, user preferences

When to Use Which

Decision Tree

Need to transform object structure?
│
├─ Nested → Flat?
│  → Use flatten()
│
├─ Flat → Nested?
│  → Use unflatten()
│
├─ Rename keys?
│  │
│  ├─ Few keys, known mapping?
│  │  → Use rename()
│  │
│  └─ All keys, pattern-based?
│     → Use mapKeys() (see Transformation guide)
│
├─ Remove sensitive fields?
│  │
│  ├─ Security/privacy concern?
│  │  → Use mask() (allowlist)
│  │
│  └─ Just want subset?
│     → Use Remeda.pick()
│
└─ Merge multiple objects?
   │
   ├─ Shallow merge OK?
   │  → Use spread: { ...a, ...b }
   │
   └─ Need deep merge?
      → Use deepMerge()

Common Patterns

// Pattern 1: API response → DB schema
pipe(
  apiResponse,
  (response) => flatten(response),
  rename(keyMapping),
  (flat) => unflatten(flat),
  validateShape(dbSchema)
)

// Pattern 2: Sanitize for logging
pipe(
  request,
  mask(safeFields),
  stripUndefined,
  (safe) => flatten(safe)
)

// Pattern 3: Config merging
pipe(
  [defaultConfig, envConfig, userConfig],
  deepMerge({ arrayStrategy: 'concat' }),
  validateShape(configSchema)
)

// Pattern 4: Form data → API payload
pipe(
  formData,
  stripUndefined,
  rename(apiKeyMapping),
  mask(allowedFields)
)

Performance Considerations

// ⚠️ flatten/unflatten on large objects
// For very deep/large objects, consider:
flatten(obj, { maxDepth: 3 })  // Limit depth

// ✅ mask is O(n) where n = number of keys
// More efficient than filterKeys for known allowlist

// ✅ deepMerge is optimized for typical configs
// For huge objects with many levels, profile first

// ✅ rename is O(n) - efficient for any size

Next Steps

On this page