deepMerge Transform

Deep recursive merge where later inputs override earlier ones at the leaf level. Type conflicts are allowed (later value wins).

Usage

Using in-memory data (recommended):

node:
  base:
    from_file: base.json
  override:
    from_file: override.json
  config:
    to_file: config.json
    transform:
      deepMerge:
        - "{{.base}}"
        - "{{.override}}"

Using file paths (via .meta):

node:
  base:
    from_file: base.json
  override:
    from_file: override.json
  config:
    to_file: config.json
    transform:
      deepMerge:
        - "{{.meta.base.path}}"
        - "{{.meta.override.path}}"

Behavior

Scenario Result
Objects Keys are merged recursively
Arrays Later array replaces earlier array
Scalars Later value replaces earlier value
Type conflicts Later type wins (e.g., string can become object)

Examples

Basic Merge

base.json:

{
  "name": "app",
  "settings": {
    "debug": false,
    "port": 8080
  }
}

override.json:

{
  "settings": {
    "debug": true,
    "timeout": 30
  }
}

Result:

{
  "name": "app",
  "settings": {
    "debug": true,
    "port": 8080,
    "timeout": 30
  }
}

Type Override

deepMerge allows changing types. If base.json has "value": "string" and override.json has "value": {"nested": "object"}, the result will have the object.

Array Replacement

Arrays are not merged element-by-element. The entire array is replaced:

base.json:

{"items": [1, 2, 3]}

override.json:

{"items": [4, 5]}

Result:

{"items": [4, 5]}

Multiple Files

You can merge more than two files. They are processed in order:

transform:
  deepMerge:
    - "{{.defaults}}"
    - "{{.environment}}"
    - "{{.local}}"

With Multi-Document Nodes

For nodes with multiple files or glob inputs, {{.nodeName}} expands to all documents in sorted order:

node:
  configs:
    from_file:
      - base.yaml
      - env.yaml
      - local.yaml
  merged:
    to_file: merged.json
    transform:
      deepMerge:
        - "{{.configs}}"    # Expands to all 3 documents

Or access specific documents:

transform:
  deepMerge:
    - "{{.configs.base}}"
    - "{{.configs.env}}"
    - "{{.configs.local}}"

Field Access

Access individual fields from documents instead of entire documents:

node:
  config:
    from_file: config.json    # {"database": {...}, "cache": {...}, "api": {...}}
  override:
    from_file: override.json
  out:
    to_file: db.json
    transform:
      deepMerge:
        - "{{.config.database}}"      # Only the database section
        - "{{.override}}"

Nested field access is supported:

transform:
  deepMerge:
    - "{{.config.services.api.database}}"     # Deeply nested field
    - "{{.override}}"

For multi-document nodes, specify document name first, then field path:

node:
  configs:
    from_glob: "configs/*.json"    # base.json, prod.json
  out:
    transform:
      deepMerge:
        - "{{.configs.base.database}}"        # 'database' field in base doc
        - "{{.configs.prod.database}}"        # 'database' field in prod doc

Error handling:

  • Missing field: "field 'nonexistent' not found in node 'config'"
  • Non-object value: "field 'port' is not an object (got float64), cannot use in merge transform"

With Glob Inputs

Glob nodes work the same way - documents are named by filename stem:

node:
  configs:
    from_glob: "configs/*.yaml"
  merged:
    to_file: merged.json
    transform:
      deepMerge:
        - "{{.configs}}"    # All matched files in sorted order

With Intermediate Nodes

Reference other nodes (processed in dependency order):

node:
  base1:
    from_file: base1.yaml
  base2:
    from_file: base2.yaml
  overrides:
    from_file: overrides.yaml

  # Intermediate node
  base_merged:
    transform:
      deepMerge:
        - "{{.base1}}"
        - "{{.base2}}"

  # Final output
  final:
    to_file: config.json
    transform:
      deepMerge:
        - "{{.base_merged}}"
        - "{{.overrides}}"

CLI Usage

Run deepMerge directly from the command line:

panconf transform deepMerge base.json override.yaml extra.toml

Output is YAML to stdout.