Skip to content

Pattern Matching and Control Flow

Most transformation logic is straightforward: map fields, apply functions, construct objects. But real-world integration always has conditional paths — different output structures based on message type, different calculations based on country, different handling for errors.

This chapter covers UTL-X's control flow mechanisms: match expressions for multi-way branching, try/catch for error recovery, and practical patterns for routing and polymorphic data.

Match Expressions

match is UTL-X's multi-way branch — like switch in Java or when in Kotlin, but more powerful because it supports value matching, guard clauses, and variable binding.

Basic Value Matching

utlx
match ($input.status) {
  "NEW" -> "Order received",
  "PROCESSING" -> "In progress",
  "SHIPPED" -> "On the way",
  "DELIVERED" -> "Completed",
  _ -> "Unknown status"
}

The _ is the wildcard — it matches anything. Always include it as the last case to handle unexpected values. Without a wildcard, an unmatched value produces null.

Matching with Guards

Add conditions after the pattern with if:

utlx
match ($input.amount) {
  n if n > 10000 -> "high-value",
  n if n > 1000 -> "medium-value",
  n if n > 0 -> "standard",
  0 -> "zero",
  _ -> "negative"
}

The variable n is bound to the matched value — you can use it in the guard condition and in the result expression.

Complex Matching

Match can branch on any expression, not just simple values:

utlx
match (getType($input.data)) {
  "string" -> {type: "text", value: $input.data},
  "number" -> {type: "numeric", value: $input.data},
  "array" -> {type: "list", count: count($input.data)},
  "object" -> {type: "record", keys: keys($input.data)},
  _ -> {type: "unknown"}
}

Match vs If/Else Chains

For two or three conditions, if/else is fine:

utlx
if ($input.country == "NL") "domestic"
else if ($input.country == "BE" || $input.country == "DE") "neighboring"
else "international"

For more than three conditions, match is clearer:

utlx
match ($input.country) {
  "NL" -> "domestic",
  "BE" -> "neighboring",
  "DE" -> "neighboring",
  "FR" -> "EU-west",
  "IT" -> "EU-south",
  "PL" -> "EU-east",
  _ -> "international"
}

The rule of thumb: two branches → if/else, three or more → match.

Try/Catch — Error Recovery

Not every input is clean. Dates might be unparseable. Numbers might be text. Fields might be missing. try/catch lets you handle errors gracefully instead of failing the entire transformation.

Basic Try/Catch

utlx
try {
  parseDate($input.dateString, "yyyy-MM-dd")
} catch (e) {
  today()    // fallback: use today's date if parsing fails
}

The try block contains the expression that might fail. If it succeeds, the result is used. If it throws an error, the catch block runs instead.

Using the Error Variable

The catch variable e contains the error message:

utlx
{
  result: try {
    toNumber($input.price) * toNumber($input.quantity)
  } catch (e) {
    0    // fallback to zero
  },
  error: try {
    toNumber($input.price) * toNumber($input.quantity)
    null    // no error
  } catch (e) {
    e    // capture the error message
  }
}

Try/Catch in Map

A common pattern — transform an array, handling errors per element instead of failing the entire batch:

utlx
map($input.records, (record) -> {
  id: record.id,
  amount: try { toNumber(record.amount) } catch (e) { 0 },
  date: try { parseDate(record.date, "yyyy-MM-dd") } catch (e) { null },
  valid: try { toNumber(record.amount); true } catch (e) { false }
})

Each record is processed independently. A bad date in record 5 doesn't prevent records 1-4 and 6+ from being transformed correctly.

When to Use Try/Catch vs Null Coalescing

utlx
// Use ?? when the value might be null/missing:
$input.discount ?? 0

// Use try/catch when the operation might FAIL:
try { parseDate($input.date, "yyyy-MM-dd") } catch (e) { null }

?? handles absence (null). try/catch handles failure (exceptions). Different problems, different tools.

Practical Control Flow Patterns

Message Type Routing

Integration messages often carry a type indicator. Route to different transformation logic based on type:

utlx
match ($input.messageType) {
  "ORDER" -> {
    type: "order",
    orderId: $input.data.orderId,
    customer: $input.data.customer,
    lines: $input.data.lines
  },
  "INVOICE" -> {
    type: "invoice",
    invoiceId: $input.data.invoiceId,
    total: $input.data.total,
    dueDate: $input.data.dueDate
  },
  "CREDIT_NOTE" -> {
    type: "credit",
    creditId: $input.data.creditId,
    amount: -abs($input.data.amount)
  },
  _ -> error(concat("Unknown message type: ", $input.messageType))
}

API Version Handling

Different API versions send different field names. Handle both:

utlx
{
  // v2 uses "fullName", v1 uses separate "firstName" + "lastName"
  name: if ($input.fullName != null) $input.fullName
        else concat($input.firstName ?? "", " ", $input.lastName ?? ""),

  // v2 uses ISO date, v1 uses US format
  date: try { parseDate($input.date, "yyyy-MM-dd") }
        catch (e) { try { parseDate($input.date, "MM/dd/yyyy") }
        catch (e2) { null } },

  // v2 includes "status", v1 doesn't
  status: $input.status ?? "ACTIVE"
}

Country-Specific Business Rules

utlx
let vatRate = match ($input.customer.country) {
  "NL" -> 21,
  "DE" -> 19,
  "FR" -> 20,
  "BE" -> 21,
  "LU" -> 17,
  "IE" -> 23,
  _ -> 0    // non-EU: reverse charge, 0% VAT
}

let isReverseCharge = vatRate == 0 && $input.customer.vatId != null

{
  subtotal: $input.total,
  vatRate: vatRate,
  vatAmount: if (isReverseCharge) 0 else $input.total * vatRate / 100,
  reverseCharge: isReverseCharge,
  total: if (isReverseCharge) $input.total else $input.total * (1 + vatRate / 100)
}

Data Cleansing with Fallbacks

utlx
{
  email: try {
    let e = lowerCase(trim($input.email))
    if (matches(e, "^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$")) e else null
  } catch (err) { null },

  phone: try {
    replace(replace(replace($input.phone ?? "", " ", ""), "-", ""), "+", "00")
  } catch (err) { null },

  amount: try {
    abs(toNumber(replace($input.amount ?? "0", ",", ".")))
  } catch (err) { 0 },

  country: upperCase(trim($input.countryCode ?? "")) ?? "XX"
}

Polymorphic Output (Different Structure Based on Input)

utlx
if (count($input.items) == 1) {
  // Single-item order: flat structure
  orderId: $input.orderId,
  product: $input.items[0].name,
  quantity: $input.items[0].qty,
  price: $input.items[0].price
} else {
  // Multi-item order: nested structure with summary
  orderId: $input.orderId,
  itemCount: count($input.items),
  items: map($input.items, (i) -> {name: i.name, qty: i.qty, price: i.price}),
  total: sum(map($input.items, (i) -> i.qty * i.price))
}

Control Flow Summary

MechanismUse whenExample
if/else2-3 simple conditionsif (active) "yes" else "no"
ternary ? :Inline conditionactive ? "yes" : "no"
?? (nullish)Default for missing valuename ?? "Unknown"
?. (safe nav)Property might not existorder?.customer?.name
match3+ conditions or complex routingmatch (type)
try/catchOperation might failtry { parseDate(...) } catch
error()Deliberately failerror("Invalid input")

The key insight: in UTL-X, every control flow construct is an expression that produces a value. There are no statements. This means you can use if/else, match, and try/catch anywhere a value is expected — inside objects, as function arguments, in array elements, nested in other expressions.

Released under AGPL-3.0.