DTL
1.x
Docs/DTL/Language specification
Open

Reading7 min
Updated2 Aug 2026
Sourcev1/specification.mdx

Language specification

This page defines the language. It stops at the interpreter boundary: datastores, HTTP and message buses are registered by the embedding host as builtins under its own namespaces, and examples that call one are marked as host-provided.

Function declaration01

fn <name>(<params>) -> <return_type>:
    <body>

-- or, for a single expression --

fn <name>(<params>) -> <return_type> => <expression>

Parameters take three forms:

name: type                    -- required
name: type = default_value    -- optional, with a default
...name: type                 -- variadic, and must be last

Types

string, int, float, bool, datetime, duration, object, any and void, plus the array variant of each: string[], float[], object[] and so on.

Both forms

fn celsius_to_fahrenheit(temp: float) -> float =>
    temp * 1.8 + 32
fn classify_temperature(temp: float, unit: string = "celsius") -> string:
    let normalized = if unit == "fahrenheit" then (temp - 32) / 1.8 else temp

    match normalized:
        when < 0    => "freezing"
        when < 15   => "cold"
        when >= 35  => "hot"

Variables02

let x = 42
let name = "sensor_01"
let values = [1, 2, 3, 4, 5]
Note

Once bound, a name cannot be reassigned. There is no assignment operator to reach for. This is what makes a function free of side effects and safe to evaluate in parallel.

Conditionals03

An if is an expression, inline or laddered.

let status = if temp > 80 then "hot" else "ok"

let category =
    if score > 90 then "excellent"
    else if score > 70 then "good"
    else if score > 50 then "average"
    else "poor"

Pattern matching04

A match arm tests a comparison, a literal, a range, or the wildcard _.

match value:
    when < 0       => "negative"
    when 0         => "zero"
    when 1..10     => "small"
    when 11..100   => "medium"
    when > 100     => "large"

match status_code:
    when "OK"      => 0
    when "WARN"    => 1
    when "ERROR"   => 2
    when _         => -1

The pipe operator05

| chains transformations left to right. It is the core ergonomic feature, and the reason a function stays readable once it does more than one thing.

-- instead of: round(avg(filter(values, x => x > 0)), 2)
values | filter(> 0) | avg() | round(2)

Pipes also chain over whatever the host exposes. If an embedder registers a query builtin returning a row list, it composes the same way:

query("sensor_readings")
    | where timestamp > $start_date
    | select temperature, humidity
    | order_by timestamp desc
    | limit 100

Collection operations06

values | map(x => x * 2)
values | filter(x => x > threshold)
values | filter(> 0)                     -- implicit argument
values | reduce(0, (acc, x) => acc + x)
values | sort()                          -- sort(desc) for descending
values | head(5)                         -- tail(10) for the other end
values | unique()
values | flatten()
values | zip(other_values)
values | group_by(x => x.category)
values | chunk(5)

Aggregation07

values | sum()          values | avg()
values | min()          values | max()
values | count()        values | stdev()
values | variance()     values | median()
values | percentile(95)
values | count_where(> 100)
values | sum_where(> 0)

Strings08

name | upper()          name | lower()
name | trim()           name | replace("old", "new")
name | split(",")       parts | join(", ")
name | starts_with("pre")
name | contains("sub")
name | substr(0, 5)

"Hello {name}, temp is {temp | round(1)}"

Dates and times09

now()                   today()
dt | add(7, "days")     dt | subtract(1, "hours")
dt | diff(other, "minutes")
dt | format("YYYY-MM-DD")
dt | year()             dt | month()
dt | day()              dt | hour()
dt | day_of_week()      -- 0 is Sunday
dt | start_of("month")  dt | end_of("week")

Maths10

abs(x)          round(x, decimals)
ceil(x)         floor(x)
power(x, n)     sqrt(x)
log(x)          log10(x)
clamp(x, min, max)
lerp(a, b, t)

Error handling11

let result = try some_function(x) catch default_value

let safe_value  = value ?? default_value    -- null coalescing
let safe_result = value?.nested?.field      -- optional chaining

Comments12

-- single line

{-
   multi-line
-}

Type casting13

value | as_float()      value | as_int()
value | as_string()     value | as_bool()
value | as_datetime("YYYY-MM-DD")

Namespaces14

Functions live in namespaces, which serve both organisation and access control. A fully-qualified name stays valid once a function is shared.

system::math::clamp          -- built in
shared::analytics::anomaly   -- shared across all users
team::data_eng::normalize    -- team-scoped
user::jane::my_helper        -- private to a user
app::iot_connector::decode   -- provided by an app

Next: the standard library, which is written in DTL itself.