Skip to content
hekla

Containers and control flow

Lists, maps, iteration in a defined order, and why every heklang program terminates.

Lists

let ids = [first_id, second_id]
let more = ids.push(order_id)
Method Returns
first() T?
push(T) List(T)
concat(List(T)) List(T)
remove(T) List(T)
contains(T) Bool
len() Int
is_empty() Bool
sum() T where T + T is a T

push, concat and remove build a new list rather than mutating one. remove removes every equal element, not the first, so it is idempotent the way a map’s is. concat takes a whole list where push takes one element, and the element types have to agree exactly.

sum exists exactly where + does, which is Int, Decimal(n) and Money(n). The element type is the result type, an empty list is that type’s zero rather than a none, and an overflow is an error the way it is for +.

There is no indexing. first() is the only positional access, and it is optional.

Maps

Method Returns
get(K) V?
set(K, V) Map(K, V)
remove(K) Map(K, V)
contains(K) Bool
keys() List(K)
values() List(V)
len() Int
is_empty() Bool

A map key must be a type that orders: Int, String, Uuid, Timestamp, an enum, or a subject whose ids are one of those, reading through to the scalar. That is the same set an entity key may use.

An empty container takes its type from the target. [] and Map.empty with nothing to infer from are a compile error naming where a type could come from.

Iteration

Three forms, each over a finite container:

for item in list { }
for index, item in list { }
for key, value in map { }

Comprehensions cover map and filter:

let names = [row.name for row in rows if row.active]

A comprehension writes its element expression down, so it needs no target to take a type from: [line.amount for line in lines] is a List(Money(2)) whether or not lines has anything in it. A target still wins where there is one, which is the coercion every declared position does.

There is no sort, map, filter or general fold method. Iteration order is already defined, a comprehension covers the middle two, and sum covers the one reduction.

Bindings

let and nothing else. There is no var, and a binding is never reassigned.

A let may not bind a name an enclosing block already bound. Without var an inner let is a second binding rather than an assignment, and one written in a loop body or a branch ends with that block:

let running = 0
for a in ns {
  let running = running + a   // rejected: `running` is already in scope
}

Rebinding in the scope that already holds the name is fine, because there it is sequential and every later read sees it: let email = email.trim().lower().

A total is [line.tax for line in lines].sum(), where a comprehension selects and sum adds. Accumulating across events is a fold arm, which is a different thing entirely.

Immutability is what makes the rest of the guarantees cheap: a fold arm that returns new state cannot alias the old one, a value passed to a helper cannot come back changed, and a replay cannot diverge because something was written twice in a different order.

Branching

if a > 10 {
  invalid "too many"
} else if a > 5 {
  reject Busy
} else {
  return
}

An else if chain is one statement rather than a nest, which is why it formats flat.

if also works in value position:

let label = if paid { "paid" } else { "outstanding" }

There is no match, no switch, and no fallthrough.

Totality

Every heklang program terminates. Four rules together, and none of them is a run-time check:

  • No while. There is no unbounded loop form.
  • No break and no continue. A for runs once per element of a finite container.
  • Recursion is rejected statically, and the diagnostic names the cycle.
  • Every path must return. A fn body that can fall off the end is an error.

A smart contract language buys termination at run time, with gas metering and a transaction that runs out halfway. Here it is not expressible in the first place, so nothing needs to bound a runaway program: nothing can run away.

That is also why hekla does not need a timeout around your code. The only unbounded thing a hekla process does is wait on the network, and that only happens inside an effect, where it is journaled and retried.

Statements

The complete list, so it is clear how little there is:

let bind a value
if / else if / else branch
for iterate
return leave the declaration, and in a fn hand a value back
reject / invalid the answer, in a command, a guard, or a fn that declared Outcome
fail the answer, in an effect
emit command only
put / patch / update / delete projector only
log / erase effect only
a bare invoke or http.* effect only, when the result is unused
a call to a void fn effect only, since only an effect-local fn may return nothing

There are no semicolons anywhere in the language. Statements are separated by newlines.