Flows

Flows are expressions that control how their contents execute and how their results are collected. Ordinary function bodies use a serial flow. Conditional, matching, and parallel flows make alternative execution strategies explicit.

Flow Types

FlowDescription
serialExecute sequentially (default)
parallelExecute concurrently
condFirst matching branch wins
cond-allAll matching branches execute
matchPattern match on types and values
match-allAll matching type/value patterns execute
|>Pipe data through transformations

Two Ways to Use Flows

Every flow can be used in two ways:

1. With fn — defines a function whose body uses that flow's scheduling and result rules:

fetch-all-modifier fn parallel (id: Str): All<Map> {
  user api-get(`/users/${id}`)
  orders api-get(`/orders/${id}`)
}

2. As an inline expression — provides local control flow:

process-inline fn (id: Str): Map {
  // Inline parallel block
  data parallel {
    user api-get(`/users/${id}`)
    orders api-get(`/orders/${id}`)
  }

  // Inline conditional
  status cond {
    is-empty(data.orders) => { "new-customer" }
    => { "returning-customer" }
  }

  {data: data, status: status}
}

The examples below show both approaches.

Serial Flow (Default)

Without a flow specifier, functions execute sequentially, returning the last value:

process fn (x: Int): Int {
  doubled mul(x, 2)     // First
  tripled mul(x, 3)     // Second
  add(doubled, tripled) // Third - returned
}
process(5)
→ 25

You can make it explicit with serial:

process-explicit fn serial (x: Int): Int {
  doubled mul(x, 2)
  tripled mul(x, 3)
  add(doubled, tripled)
}

Parallel Flow

Request dependency-aware concurrency with parallel:

fetch-all fn parallel (user-id: Str): All<Map> {
  user api-get(`/users/${user-id}`)
  orders api-get(`/orders/${user-id}`)
  preferences api-get(`/prefs/${user-id}`)
}
fetch-all("user-123")
→ {user: {...}, orders: {...}, preferences: {...}}

Parallelism is explicit: Hot never changes an ordinary serial body into a parallel one. Within a parallel flow, dependency scheduling is automatic.

parallel, cond-all, and match-all naturally return all branch results. Use All<Map> or All<Vec> when declaring that collected shape explicitly. A plain annotation such as : Map or : Int opts a naturally collect-all flow out of collection and describes its single final value instead.

When to Use Parallel

Use parallel when:

  • Operations involve I/O (HTTP, database, file system)
  • You want to speed up multiple slow operations

Hot automatically analyzes dependencies and executes in "levels" - variables at the same level run concurrently, but levels execute in order:

// Parallel with automatic dependency resolution
enrich-user fn parallel (id: Str): All<Map> {
  user ::api/get-user(id)           // Level 0
  orders ::api/get-orders(user.id)  // Level 1 (depends on user)
  prefs ::api/get-prefs(user.id)    // Level 1 (depends on user)
  summary build-summary(orders, prefs) // Level 2 (depends on orders, prefs)
}
// user runs first, then orders+prefs run in parallel, then summary

Dependency analysis follows references between Hot bindings. It cannot infer conflicts in external state. Independent branches that write the same database row, file, or remote resource may race even when no Hot value connects them.

If one branch fails, the flow fails. Sibling work that already started cannot be rolled back automatically, so independently scheduled side effects should be idempotent or explicitly coordinated.

Conditional Flow

Use cond for conditional branching. The first matching condition wins:

classify fn cond (x: Int): Str {
  lt(x, 0) => { "negative" }
  eq(x, 0) => { "zero" }
  => { "positive" }
}
classify(-5)  → "negative"
classify(0)   → "zero"
classify(10)  → "positive"

The => arrow separates the condition from the result. A branch without a condition is the default case.

Conditions are checked for truthiness: any value that isn't false, null, or an Err is considered true — including 0, "", [], and {}. The same rule backs and, or, not, and is-truthy; test emptiness explicitly with is-empty. This means you can use values directly as conditions:

get-name fn cond (user: Map): Str {
  user.nickname => { user.nickname }  // Truthy if nickname exists and isn't null
  user.name => { user.name }
  => { "Anonymous" }
}
get-name({nickname: "Bob", name: "Robert"}) → "Bob"
get-name({name: "Alice"})                   → "Alice"
get-name({})                                → "Anonymous"

Multiple Conditions

grade fn cond (score: Int): Str {
  gte(score, 90) => { "A" }
  gte(score, 80) => { "B" }
  gte(score, 70) => { "C" }
  gte(score, 60) => { "D" }
  => { "F" }
}
grade(95) → "A"
grade(75) → "C"
grade(55) → "F"

Named Branches

Give branches names for debugging or result identification:

categorize fn cond (x: Int): Str {
  lt(x, 0) => negative { "negative" }
  eq(x, 0) => zero { "zero" }
  => positive { "positive" }
}
categorize(-5) → "negative"
categorize(0)  → "zero"
categorize(5)  → "positive"

Complex Conditions

Any expression that returns a boolean works:

validate fn cond (user: Map): Result {
  is-null(user.email) => { err("Email required") }
  not(valid-email(user.email)) => { err("Invalid email") }
  lt(length(user.password), 8) => { err("Password too short") }
  => { ok(user) }
}

Conditional-All Flow

Use cond-all when you want all matching branches to execute:

apply-discounts fn cond-all (order: Map): All<Map> {
  order.is-member => member { "10% member discount" }
  gt(order.total, 100) => shipping { "Free shipping" }
  order.has-coupon => coupon { "Coupon applied" }
  => standard { "Standard pricing" }
}
apply-discounts({is-member: true, total: 150, has-coupon: true})
→ {member: "10% member discount", shipping: "Free shipping", coupon: "Coupon applied"}

Use Cases for cond-all

  • Applying multiple rules/transformations
  • Collecting all matching categories
  • Running side effects for all matches
  • Validation that collects all errors
validate-all fn cond-all (user: Map): All<Map> {
  is-null(user.name) => name { "Name required" }
  is-null(user.email) => email { "Email required" }
  lt(length(user.password), 8) => password { "Password too short" }
  // Returns ALL validation errors as a map, not just the first
}
validate-all({name: null, email: null, password: "short"})
→ {name: "Name required", email: "Email required", password: "Password too short"}

Match Flow

Use match to pattern match on types and literal values. The first matching pattern wins:

Direction enum {
  Up,
  Down,
  Left,
  Right
}
describe fn match (dir: Direction): Str {
  Direction.Up => "Going up"
  Direction.Down => "Going down"
  Direction.Left => "Going left"
  Direction.Right => "Going right"
}

up Direction.Up
describe(up)  // → "Going up"

Exhaustiveness

A match on a closed enum must cover every variant or include a _ / bare => default arm. Missing variants produce non-exhaustive-match at compile time. Union arms (A | B) count every variant they name toward coverage, and an Any arm covers everything (it also satisfies the open-enum default requirement below).

A match on an open enum MUST include a _ / bare => default arm, because additional variants can be enrolled later via Source -> Enum.Variant arrows. Missing the default produces open-enum-match-missing-default.

Animal enum open { Dog, Cat }

label fn match (a: Animal): Str {
  Animal.Dog => { "dog" }
  Animal.Cat => { "cat" }
  _ => { "other" }              // required for open enums
}

Value Matching

Match against literal values — Int, Dec, Str, Bool, Null, Vec, Map:

status-message fn match (code: Int): Str {
  200 => { "ok" }
  404 => { "not found" }
  500 => { "server error" }
  => { "unknown" }
}

Mixed Type and Value Arms

Type and value arms can coexist. Arms are evaluated top-to-bottom; first match wins:

describe fn match (value: Any): Str {
  null => { "null" }
  0 => { "zero" }
  "" => { "empty string" }
  Int => { "integer" }
  Str => { "string" }
  => { "other" }
}

Union Arms

Combine several patterns in one arm with | — the arm matches if any atom matches. Atoms are the same pattern forms as single arms: types, enum variants, literal values, and fully qualified type paths (::hot::type/Str), mixed freely:

describe fn match (value: Any): Str {
  "" | Null => { "blank" }
  Int | Dec => { "number" }
  "yes" | "y" | true => { "affirmative" }
  => { "other" }
}

Enum variants union the same way, and union arms count toward exhaustiveness — this match is exhaustive without a default arm:

Shape enum { Circle, Square, Triangle }

classify fn match (s: Shape): Str {
  Shape.Circle | Shape.Square => { "round-ish" }
  Shape.Triangle => { "pointy" }
}

Bindings receive the matched value as usual: Str | Null (v) => { ... }.

Optional-Type Sugar

T? in a match arm means T | Null, exactly as in signatures:

greet fn (name: Str?): Str {
  match name {
    Str? => { `Hello, ${or(name, "stranger")}` }  // same as Str | Null
    _ => { "Hello, whatever you are" }
  }
}

The Any Pattern

Any is the top type: it matches every value. An Any arm acts like a default arm (and satisfies exhaustiveness), but unlike _ it can carry a binding:

kind match value {
  Str => { "string" }
  Any (v) => { `something else: ${v}` }
}

Expression Subjects

The match subject can be any expression — it is evaluated once:

result match length(name) {
  0 => { "empty" }
  5 => { "five chars" }
  => { "other" }
}

Vec and Map Arms

Match collections by full structural equality:

result match coords {
  [0, 0] => { "origin" }
  [1, 0] => { "unit x" }
  => { "other" }
}

Inline Match

Use match inline to branch on a value:

result get-result()

message match result {
  Result.Ok => `Success: ${result}`
  Result.Err => `Error: ${result}`
}

Type-Level Matching

Match any variant of a type:

// Matches any Result variant
is-result match value {
  Result => true
  => false
}

Match Functions with Extra Arguments

Match flow functions can have additional arguments beyond the matched value:

Direction enum {
  Up,
  Down,
  Left,
  Right
}
describe-direction fn match (dir, prefix: Str): Str {
  Direction.Up => concat(prefix, " going up")
  Direction.Down => concat(prefix, " going down")
  Direction.Left => concat(prefix, " going left")
  Direction.Right => concat(prefix, " going right")
}
describe-direction(Direction.Up, "We are")
→ "We are going up"

Match-All Flow

Use match-all when you want all matching patterns to execute:

Trait enum {
  Flying,
  Swimming,
  Walking
}
describe-traits fn match-all (trait): All<Map> {
  Trait.Flying => "Can fly"
  Trait.Swimming => "Can swim"
  Trait.Walking => "Can walk"
}
describe-traits(Trait.Flying)
→ {"Trait.Flying": "Can fly"}

Results are keyed by the arm's pattern; a union arm produces a single key joining its atoms (e.g. "Int | Dec").

Match Result Shape

Like other flows, match supports All annotations to collect branch results. Use plain return types for single values and All<Vec> / All<Map> for collected results.

Bare All is allowed only where the language already has a natural collect-all default: parallel, cond-all, and match-all. On serial, pipe, cond, and match, use explicit All<Vec> or All<Map> to make the collection shape clear.

// match defaults to one winning result
// match-all defaults to All<Map> (keyed by branch)

// Get results as vector
traits: All<Vec> match-all creature {
  Trait.Flying => "flies"
  Trait.Swimming => "swims"
}

Pipe Flow

The pipe |> chains transformations. The piped value becomes the first argument of the next function:

result 5 |> add(2) |> mul(3)
// 5 |> add(2) → add(5, 2) → 7
// 7 |> mul(3) → mul(7, 3) → 21

Collection Pipelines

Pipes shine with collection operations:

// Using % placeholder lambdas for concise single-param operations
result [1, 2, 3, 4, 5]
  |> map(mul(%, 2))                    // [2, 4, 6, 8, 10]
  |> filter(gt(%, 5))                  // [6, 8, 10]
  |> reduce((a, x) { add(a, x) }, 0)  // 24 (multi-param: use explicit lambda)

Pipes and % — How They Compose

Two rules govern how pipes and % interact:

  1. The pipe supplies the piped value as the first argument. Don't add % for that — a pipe stage is already a partial call:
result 10
  |> mul(2)     // mul(10, 2) → 20
  |> add(5)     // add(20, 5) → 25
  1. % creates a lambda only inside an argument that expects a function — the higher-order arguments of map, filter, reduce, and friends:
[1, 2, 3] |> map(mul(%, 2))    // % is each element → [2, 4, 6]

A bare % in a pipe stage that isn't a function-typed argument is a compile error:

10 |> mul(%, 2)
// error: Placeholder `%` has no enclosing parameter slot of type `Fn`
// to bind to. The pipe already passes 10 as the first argument — write
// `10 |> mul(2)` instead.

When you need a lambda where Hot wouldn't create one automatically, mark the boundary explicitly with %(expr) — see Explicit Lambda Boundary.

Real-World Pipeline

process-users fn (users: Vec<Map>): Vec<Str> {
  users
    |> filter(%.active)
    |> map(%.email)
    |> filter(ends-with(%, "@company.com"))
    |> map(lowercase(%))
}

Combining Flows

Use flows within function bodies:

process-order fn (order: Map): Result {
  // Validate first (conditional)
  validation cond {
    is-null(order.items) => { err("No items") }
    eq(length(order.items), 0) => { err("Empty order") }
    => { ok(order) }
  }

  // Then enrich in parallel (returns a map)
  enriched parallel {
    customer fetch-customer(order.customer-id)
    inventory check-inventory(order.items)
    shipping calculate-shipping(order)
  }

  // Return combined result (access via enriched.*)
  ok({
    order: order,
    customer: enriched.customer,
    inventory: enriched.inventory,
    shipping: enriched.shipping
  })
}

Flow vs Function

Flows are expressions. Combining fn with a flow creates a callable function whose body uses that flow's scheduling and result rules:

// Function with conditional flow
classify fn cond (x: Int): Str {
  lt(x, 0) => { "negative" }
  => { "positive" }
}

// Inline flow expression inside a function body
process fn (data: Map): Result {
  result cond {
    is-null(data) => { err("No data") }
    => { ok(data) }
  }
  result
}

Flow Result Shape

Flow result shape controls whether a flow returns its single produced value or all produced values. Use a plain type annotation for the single value case and All<Vec> / All<Map> when you want a collected result:

// Single value (the default for serial, cond, match, and pipe)
result: Int serial {
  a 1
  b 2
}

// All values as a vector
values: All<Vec> serial {
  a 1
  b 2
}

// All values as a map keyed by branch or variable name
data: All<Map> parallel {
  user ::api/get-user(id)
  orders ::api/get-orders(id)
}

// Any other type opts a collect-all flow OUT of collection: the
// annotation states the type of the single final value. On
// single-value flows a plain annotation is an ordinary type check.
last: Int parallel {
  a 1
  b 2
}

Bare All is accepted only on natural collect-all flows (parallel, cond-all, and match-all). Use All<Vec> or All<Map> on other flows.

Annotations are not enforced at runtime, but hot check reports an annotation-mismatch warning when an annotation names a type the value can never be (for example x: Int parallel { ... } whose final value is a Str).

Default Flow Shapes

Each flow type has a sensible default:

FlowDefaultBehavior
serialSingle valueReturns the last expression's value
parallelAll<Map>Returns all results as a map keyed by variable name
condSingle valueReturns the matching branch's value
cond-allAll<Map>Returns all matching results as a map keyed by branch name
matchSingle valueReturns the matching arm's value
match-allAll<Map>Returns all matching results as a map keyed by pattern
|> (pipe)Single valueReturns the final piped value

Explicit Result Shapes

Override the default when you need different results:

// Parallel defaults to All<Map>
data parallel {
  user ::api/get-user(id)
  orders ::api/get-orders(id)
  prefs ::api/get-prefs(id)
}
// => {user: ..., orders: ..., prefs: ...}

// Bare All is accepted on collect-all flows and keeps the natural map shape
data: All parallel {
  user ::api/get-user(id)
  orders ::api/get-orders(id)
}
// => {user: ..., orders: ...}

// Parallel with All<Vec> - get results as a vector
values: All<Vec> parallel {
  a fetch-a()
  b fetch-b()
  c fetch-c()
}
// => [<a-result>, <b-result>, <c-result>]

// cond-all defaults to All<Map>
results cond-all {
  check-a() => a { "A passed" }
  check-b() => b { "B passed" }
  check-c() => c { "C passed" }
}
// => {a: "A passed", c: "C passed"} (if A and C pass)

// cond-all with All<Vec> - collect as vector (no branch names)
discounts: All<Vec> cond-all {
  is-member => { "10% off" }
  gt(total, 100) => { "Free shipping" }
  has-coupon => { "Coupon applied" }
}
// => ["10% off", "Free shipping"] (if member with $150 order, no coupon)

// Pipe with All<Vec> - collect all intermediate values
steps: All<Vec> 5 |> add(2) |> mul(3)
// => [5, 7, 21]

Parallel scheduling is defined by named bindings and their dependencies. Standalone, unbound expressions do not become collected result slots; bind every concurrent operation even when using All<Vec>. A fn parallel definition can collect unbound body expressions into an explicit All<Vec>, but named bindings work consistently in both forms. Branches complete independently, so an Err remains in its branch's slot rather than cancelling siblings. It propagates when ordinary code later consumes that result.

Because branches are independent slots, a deep-path assignment into an existing binding (st.a.b 99) is rejected at compile time inside parallel — both the standalone block and fn parallel forms: concurrent writes into a shared root have no defined order. Bind a new name in the branch and merge after the flow, or use a serial flow. The check does not look inside nested flows: a serial { } block nested in a parallel branch can still write to an outer binding, but its merge order across branches is undefined — treat that pattern as unsupported.

Summary

FlowUse When
serialSequential execution (default)
parallelConcurrent execution with automatic dependency resolution
condChoose one branch based on conditions
cond-allExecute all matching branches
matchPattern match on types and values
match-allExecute all matching type/value patterns
|>Chain transformations on data

Flows make Hot's scheduling and result behavior explicit. You always know whether operations run in sequence, parallel, or conditionally.