Flows

Flows control how expressions execute. They're one of Hot's most powerful features, enabling parallel execution, conditional branching, and data pipelines.

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. As a function modifier — defines the entire function's execution model:

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

2. Inline within any expression — for 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

Execute expressions concurrently with parallel:

fetch-all fn parallel (user-id: Str): 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: {...}}

This is much faster than sequential execution when operations are independent.

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): 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

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 or null is considered true. 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(len(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): 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): 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 match fn (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.

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" }
}

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): Str {
  Trait.Flying => "Can fly"
  Trait.Swimming => "Can swim"
  Trait.Walking => "Can walk"
}
describe-traits(Trait.Flying)
→ {"Trait.Flying": "Can fly"}

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 with Lambdas

Insert custom transformations using explicit lambdas or %:

result 10
  |> mul(%, 2)     // 20
  |> add(%, 5)     // 25

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 part of functions, not standalone. The fn keyword combined with a flow creates a function:

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

// Standalone flow (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)
}

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.

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]

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 execution model explicit. You always know whether operations run in sequence, parallel, or conditionally.