Skip to content

YAML Transformations ​

YAML is the configuration language of the cloud-native era. Kubernetes manifests, Docker Compose files, GitHub Actions workflows, Helm charts, Ansible playbooks, OpenAPI specs — they're all YAML. If you work in DevOps, platform engineering, or API management, you will transform YAML.

YAML is a superset of JSON — every valid JSON document is also valid YAML. This means YAML maps to UDM the same way JSON does for most structures: objects, arrays, strings, numbers, booleans, and null. But YAML adds features that JSON lacks: anchors and aliases, multi-document streams, block vs flow styles, and comments. UTL-X handles all of these.

YAML and UDM ​

Like JSON, YAML maps naturally to UDM:

YAMLUDMNotes
Mapping (key: value)ObjectKey order preserved
Sequence (- item)ArrayDirect mapping
StringScalar (String)Quoted or unquoted
IntegerScalar (Number)Parsed as Long
FloatScalar (Number)Parsed as Double
Boolean (true/false)Scalar (Boolean)Also yes/no, on/off
Null (null/ )NullAlso empty value
TimestampDateTimeAuto-parsed by default

Anchors and Aliases ​

YAML anchors (&name) and aliases (*name) are resolved transparently during parsing. By the time your transformation sees the data, aliases have been replaced with the actual values:

yaml
defaults: &defaults
  timeout: 30
  retries: 3

production:
  <<: *defaults
  timeout: 60

In your transformation, $input.production is a plain object with timeout: 60 and retries: 3. The anchor mechanism is invisible — you work with the resolved data.

Multi-Document Streams ​

YAML supports multiple documents in one stream, separated by ---:

yaml
---
name: Alice
role: admin
---
name: Bob
role: user

UTL-X parses multi-document YAML as an array of documents. Use the yamlSplitDocuments function or the multi-document parser option to access individual documents:

utlx
let docs = yamlSplitDocuments($input)
docs[0].name    // "Alice"
docs[1].role    // "user"

Reading YAML ​

YAML input works identically to JSON — dot notation, bracket notation, safe navigation:

utlx
$input.server.port              // 8080
$input.database.credentials.password   // nested access
$input.services[0].name         // first service name
$input.features?.experimental?.enabled ?? false  // safe with default

Timestamps ​

YAML has native timestamp support. Values like 2026-04-30 and 2026-04-30T14:30:00Z are automatically parsed as date/time values — unlike JSON where dates are always strings:

yaml
created: 2026-04-30
updated: 2026-04-30T14:30:00Z
utlx
$input.created    // already a date — no parseDate() needed
$input.updated    // already a datetime

This auto-parsing can occasionally be surprising — a value like 2024-01-01 that you intended as a string becomes a date. If this causes issues, the parser can be configured to disable timestamp parsing.

Boolean Variants ​

YAML is more liberal with booleans than JSON. All of these are parsed as boolean true: true, True, TRUE, yes, Yes, YES, on, On, ON. And for false: false, False, FALSE, no, No, NO, off, Off, OFF.

This matters when processing configuration files that use yes/no or on/off conventions — they arrive as booleans in your transformation, not strings.

Writing YAML ​

Block Style (Default) ​

UTL-X outputs YAML in block style by default — human-readable, indented with 2 spaces:

utlx
%utlx 1.0
input json
output yaml
---
{
  server: $input.hostname,
  port: $input.port,
  database: {
    host: $input.db.host,
    name: $input.db.name,
    credentials: {
      username: $input.db.user,
      password: $input.db.pass
    }
  }
}

Output:

yaml
server: prod-01
port: 8080
database:
  host: db.internal
  name: myapp
  credentials:
    username: admin
    password: secret123

Number Formatting ​

Whole numbers are output without a decimal point — 42, not 42.0. This matches YAML conventions and prevents downstream tools from interpreting integers as floats.

XML Attributes in YAML ​

When converting XML to YAML, attributes use the @ prefix — the same convention as JSON output:

yaml
'@id': ORD-001
Customer: Alice
Total: 299.99

With writeAttributes: true, leaf elements with attributes preserve both:

yaml
Total:
  '@currency': EUR
  '#text': 299.99

See Chapter 23 for the full attribute handling design.

YAML Output Options ​

utlx
output yaml                              // block style (default)
output yaml {writeAttributes: true}      // preserve XML attributes in output

The writeAttributes option is the only YAML-specific output option currently wired from the header. The serializer uses sensible defaults: 2-space indent, 80-character line width, Unix line breaks, block style, Unicode allowed.

The --- Document Start Marker ​

YAML's --- document start marker is not emitted by default. The serializer has an explicitStart option internally (default false), but it is not currently exposed as a header option. This means UTL-X YAML output never starts with --- unless you're using multi-document output via yamlMergeDocuments().

If a downstream system requires the --- marker, the workaround is to prepend it as a string operation after serialization. In most cases, a missing --- is not a problem — YAML parsers treat it as optional for single-document files. It is only required when separating multiple documents in one stream.

YAML-Specific Functions ​

parseYaml ​

Parse a YAML string embedded within a transformation:

utlx
let config = parseYaml(yamlString)
config.server.port    // 8080

Use this when YAML is a value inside your data — a YAML string in a JSON field, or YAML embedded in an API response. For normal YAML file processing, use input yaml in the header.

Multi-Document Functions ​

utlx
// Split a multi-document YAML stream into individual documents
let docs = yamlSplitDocuments(yamlString)

// Get a specific document by index
let second = yamlGetDocument(yamlString, 1)

// Merge multiple documents back into a stream
let stream = yamlMergeDocuments(docs)

Query and Modify Functions ​

utlx
// Query a path in a YAML structure
yamlPath($input, "database.host")

// Set a value at a path (returns new structure)
yamlSet($input, "database.port", 5433)

// Delete a path (returns new structure)
yamlDelete($input, "database.credentials.password")

// Check if a path exists
yamlExists($input, "features.experimental")

// Get all keys or values
yamlKeys($input.database)       // ["host", "name", "port", ...]
yamlValues($input.database)     // ["db.internal", "myapp", 5432, ...]

Deep Merge ​

Merge two YAML structures, with the second overriding the first — useful for configuration overlays:

utlx
let base = parseYaml(baseConfig)
let overlay = parseYaml(envConfig)
let merged = yamlDeepMerge(base, overlay)

This is the UTL-X equivalent of Helm's values merging or Kustomize's strategic merge — but as a function you control in your transformation.

Dynamic Keys ​

This is one of the most important YAML topics — and it applies equally to JSON. Many real-world YAML files use data as keys: server names, environment names, locale codes, feature flags. The keys are not fixed by a schema — they come from the data itself.

The Problem ​

Consider a typical server configuration:

yaml
servers:
  production:
    host: prod-db.example.com
    port: 5432
  staging:
    host: staging-db.example.com
    port: 5432
  development:
    host: localhost
    port: 5433

The keys production, staging, development are not fixed — a new environment could appear tomorrow. You cannot write $input.servers.production if you don't know the environment names in advance. And you cannot use map() because servers is an object (with named keys), not an array.

This pattern appears everywhere:

  • Kubernetes: annotations and labels are key-value maps with arbitrary keys

  • i18n files: locale codes as keys (en, nl, de, fr)

  • Feature flags: feature names as keys with boolean or config values

  • Data contracts: model names as keys with schema definitions

  • Terraform: resource names as keys

Reading Dynamic Keys ​

UTL-X provides a complete toolkit for working with objects whose keys are unknown at design time:

utlx
// Get all key names as an array
keys($input.servers)                    // ["production", "staging", "development"]

// Get all values as an array (keys discarded)
values($input.servers)                  // [{host: "prod-db...", ...}, ...]

// Check if a key exists
hasKey($input.servers, "production")    // true

// Access by dynamic key (bracket notation)
let env = "staging"
$input.servers[env]                     // {host: "staging-db...", port: 5432}

// Wildcard — all values as array (equivalent to values($input.servers) above)
$input.servers.*                        // [{host: "prod-db...", ...}, ...]

// Get key-value pairs for iteration
entries($input.servers)
// [["production", {host: "prod-db..."}], ["staging", {host: "staging-db..."}], ...]

Iterating Over Dynamic Keys ​

The entries() function is the key to working with dynamic keys — it converts an object into an array of [key, value] pairs that you can map, filter, and reduce:

utlx
// List all environments with their hosts
entries($input.servers) |> map((entry) -> {
  environment: entry[0],
  host: entry[1].host,
  port: entry[1].port
})
// [{environment: "production", host: "prod-db...", port: 5432}, ...]

Transform keys and values:

utlx
// Transform keys (e.g., uppercase environment names)
mapKeys($input.servers, (key) -> toUpperCase(key))
// {PRODUCTION: {...}, STAGING: {...}, DEVELOPMENT: {...}}

// Transform values (e.g., add a computed field)
mapValues($input.servers, (config) -> {
  ...config,
  connectionString: concat("postgres://", config.host, ":", toString(config.port))
})

// Transform both keys and values
mapEntries($input.servers, (key, value) -> {
  key: toUpperCase(key),
  value: { ...value, env: key }
})

Filter entries:

utlx
// Keep only non-production environments
filterEntries($input.servers, (key, value) -> key != "production")

// Check if any server uses a non-standard port
someEntry($input.servers, (key, value) -> value.port != 5432)

// Check if all servers have a host defined
everyEntry($input.servers, (key, value) -> value.host != null)

Generating Dynamic Keys (Output) ​

When you need to produce YAML or JSON with dynamic keys, use fromEntries() — it builds an object from an array of [key, value] pairs:

utlx
%utlx 1.0
input json
output yaml
---
{
  servers: fromEntries(
    map($input.serverList, (server) -> [
      server.environment,         // dynamic key: "production", "staging", etc.
      {
        host: server.host,
        port: server.port
      }
    ])
  )
}

Input:

json
{"serverList": [
  {"environment": "production", "host": "prod-db.example.com", "port": 5432},
  {"environment": "staging", "host": "staging-db.example.com", "port": 5432}
]}

Output:

yaml
servers:
  production:
    host: prod-db.example.com
    port: 5432
  staging:
    host: staging-db.example.com
    port: 5432

The array of servers became an object keyed by environment name. This is the inverse of entries() — entries() converts object-with-dynamic-keys to array, fromEntries() converts array back to object-with-dynamic-keys.

Computed Property Names ​

For simple cases where you need one or two dynamic keys, use computed property names with bracket syntax:

utlx
{
  [$input.environmentName]: {
    host: $input.host,
    port: $input.port
  }
}

The brackets around $input.environmentName evaluate the expression and use the result as the property name. This is the same syntax as JavaScript's computed property names.

Dynamic Keys Summary ​

TaskFunctionDirection
Get all keyskeys(obj)Read
Get all valuesvalues(obj)Read
Get key-value pairsentries(obj)Read
Check key existshasKey(obj, key)Read
Access by variableobj[variable]Read
Access allobj.*Read
Transform keysmapKeys(obj, fn)Read/Write
Transform valuesmapValues(obj, fn)Read/Write
Transform bothmapEntries(obj, fn)Read/Write
Filter entriesfilterEntries(obj, fn)Read/Write
Build from pairsfromEntries(array)Write
One dynamic key{[expr]: value}Write

Common YAML Patterns ​

Kubernetes Manifest Transformation ​

Update a Deployment's image tag and replica count:

utlx
%utlx 1.0
input yaml
output yaml
---
{
  ...$input,
  spec: {
    ...$input.spec,
    replicas: 3,
    template: {
      ...$input.spec.template,
      spec: {
        ...$input.spec.template.spec,
        containers: map($input.spec.template.spec.containers, (c) -> {
          ...c,
          image: if (c.name == "app") "myapp:v2.1.0" else c.image
        })
      }
    }
  }
}

The spread operator (...) copies all existing fields and lets you override specific ones — essential for YAML transformations where you want to change one field without listing all the others.

Docker Compose Environment Injection ​

Add environment variables to a service:

utlx
%utlx 1.0
input yaml
output yaml
---
{
  ...$input,
  services: {
    ...$input.services,
    app: {
      ...$input.services.app,
      environment: {
        ...$input.services.app.environment,
        DATABASE_URL: "postgres://prod-db:5432/myapp",
        LOG_LEVEL: "warn"
      }
    }
  }
}

Configuration File Migration (JSON to YAML) ​

Convert a JSON config to YAML — often needed when migrating tools:

utlx
%utlx 1.0
input json
output yaml
---
$input

A pass-through transformation is all you need. JSON is a subset of YAML, so every JSON structure converts to valid YAML. The YAML output is more readable — no quotes around keys, indentation instead of braces.

OpenAPI Spec Processing ​

Extract all endpoints from an OpenAPI 3.0 spec:

utlx
%utlx 1.0
input yaml
output json
---
flatten(map(keys($input.paths), (path) ->
  map(keys($input.paths[path]), (method) -> {
    let op = $input.paths[path][method]
    path: path,
    method: toUpperCase(method),
    operationId: op.operationId,
    summary: op.summary ?? "",
    tags: op.tags ?? []
  })
))

Helm Values Override ​

Generate environment-specific Helm values from a base config:

utlx
%utlx 1.0
input: base yaml, env yaml
output yaml
---
yamlDeepMerge($base, $env)

The base values.yaml provides defaults. The environment overlay (production.yaml) overrides specific values. yamlDeepMerge handles nested structures — it merges recursively, not just at the top level.

YAML vs JSON: When to Use Which ​

Use YAML whenUse JSON when
Human readability matters (configs, docs)Machine consumption (APIs, data exchange)
Comments are neededStrict parsing is required
Deep nesting (YAML indentation is cleaner)Interop with JavaScript/web tooling
Multi-document streamsSingle-document payloads
Kubernetes, Helm, Ansible, CI/CDREST APIs, message queues, databases

UTL-X treats both as first-class formats. Converting between them is trivial — the UDM is the same. The only loss going YAML to JSON: comments and anchors disappear (JSON has neither). Going JSON to YAML: lossless — YAML is a superset.

Released under AGPL-3.0.