heklang 0.5 · the module language for hekla
A total language for event-sourced logic.
There is no while. Recursion is rejected statically and the error prints the cycle as a path. A for runs once per element of a finite container, and every path must return.
Every program terminates. A smart contract language buys that guarantee at run time with gas metering. Here it is not expressible in the first place.
$ cargo binstall hek
event @order.placed {
order_id: Uuid,
customer_id: Int,
email: String @subject(customer_id) @max(200),
total: Money(2),
}
event @order.cancelled {
order_id: Uuid,
customer_id: Int,
}
refusal TooManyOpen "this customer has too many open orders"
command PlaceOrder(order_id: Uuid, customer_id: Int, email: String, total: Money(2)) {
// What this folds is what it conflicts on: if another writer lands in the same
// slice first, the append is rejected and the whole command retries.
fold open_orders: Int = 0
on @order.placed(customer_id) => open_orders + 1
on @order.cancelled(customer_id) => open_orders - 1
if open_orders >= 10 {
return reject TooManyOpen
}
emit @order.placed { order_id, customer_id, email, total }
}What a module may say.
Each kind is a different set of capabilities, and the difference is grammatical rather than conventional. A projector that tried to call out would not parse. Alongside these, a module declares events, records, enums, constants, pure fn helpers, deployment secrets and tests.
Exact, and never coerced.
Type equality is structural and exact. Nothing widens, nothing coerces numerically, and there is no subtyping. A numeric literal takes its type from the position it is written into, and more decimal places than the target holds is a compile error rather than a silent round.
There is no null.
T? is the only absence: no empty string standing in for nothing, no zero meaning missing. You get unwrap_or, presence tests, and flow-sensitive narrowing where a branch that proves a value present makes it a T. There is no unwrap, no ? operator and no expect, so no expression in the language panics on absence.
Money is its own type.
Money(2) is a scaled integer, distinct from Decimal(2) purely so it can carry an operator table the checker enforces. An amount times a rate is an amount, an amount over an amount is a rate, and an amount plus a rate is a type error. Where a result is not exactly representable the bare operator is refused and you name a rounding mode.
Sealed cannot be spelled.
@subject(customer_id) on an event field is the only way to make one. The seal rides on the value through bindings, folds, helpers and read-model columns. Three things may be done to it: move it into a position sealed under the same subject, ask whether it is present, or reveal it inside an effect arm. Everything else is a compile error.
Every way a program can be rejected.
The code is an enum, so the compiler checks that every diagnostic the implementation raises is one of these forty-four. That is what makes the set publishable: the specification lists the whole taxonomy rather than the part someone remembered to write down, and each entry carries its cause and its fix.
The names are the language in miniature. seal-boundary is sealed content leaving without a reveal. erase-order is a reveal reachable from an erase. self-trigger is an effect that can feed itself. Reading the list below is a fair summary of what heklang will not let you write.
There is no separate type-checking phase to reach. Every static check lives in the parser, so a module either parses and checks or does neither.
a.hk:2:41 [type-mismatch] expected String, found String? |2 | emit @order.placed { order_id, name: text } | ^^^^ = `unwrap_or` gives it a fallback, or a branch that proves it present makes it a String without one- bad-number
- unterminated-string
- unknown-escape
- bad-path
- unexpected-character
- expected-token
- declared-twice
- not-declared
- not-in-scope
- unknown-member
- unknown-type
- type-mismatch
- bad-operands
- bad-literal
- bad-type
- needs-target-type
- not-a-value
- arity
- missing-field
- duplicate-field
- unknown-annotation
- bad-annotation
- empty-declaration
- arm-shape
- entity-shape
- event-shape
- refusal-shape
- stage-shape
- no-zero-value
- wrong-context
- impure-fn
- fold-restriction
- arm-only
- return-shape
- seal-boundary
- secret-boundary
- erase-subject
- erase-order
- test-shape
- recursive-fn
- recursive-guard
- max-tightening
- self-trigger
- const-cycle
The read side and the world side.
A projector is a pure fold into rows. An effect is the one place a program reaches outside, and every call it makes is journaled, so a replay answers from the journal instead of sending twice. Both are below.
projector CustomerOrders {
entity Order {
order_id: Uuid @key,
customer_id: Int @index,
email: String? @max(200),
shipping_address: String? @max(200),
}
on @order.placed { order_id, customer_id, email, shipping_address } {
put Order { order_id, customer_id, email, shipping_address }
}
}effect NotifyCustomer {
on @order.placed as e { order_id, @key customer_id, email } {
// Inclusive of the triggering event, so a customer's first order leaves this at 1.
fold orders: Int = 0
on @order.placed(customer_id) => orders + 1
if email.is_none() {
log("order {order_id} has no address to confirm to")
return
}
let response = http.post(
"https://mail.example/confirm",
{
"to": reveal(email),
"order_id": order_id,
"first_order": orders == 1,
},
)
if response.status >= 400 {
log("confirmation rejected with status {response.status}")
}
}
}What is not in the language.
Each of these is a decision rather than an omission. A rule that cannot be written does not need a reviewer to catch it, which is the same argument the whole language rests on.
- nowhile, break, continue
- norecursion
- nonull
- nounwrap, ?, expect
- norandom(), uuid4()
- noimport or manifest
- nosemicolons
- nomutable bindings
- noclosures, generics
- nooperator overloading
- noregular expressions
- noset or tuple type
- nostring concatenation with +
- noindexing
- noblock comments
- nobuild step
hek
The checker, test runner, formatter and digest tool are one binary. A path is a directory or a single file and defaults to the current one, and a directory is one program however its files are arranged.
$ hek check
Parse every .hk file under the path as one program, reporting every mistake rather than only the first. Running hek with no subcommand does this and then runs the tests.
$ hek test
The same, then run every test declaration in it.
$ hek fmt
Rewrite canonically at 90 columns. --check makes it a gate. Three properties are checked rather than asserted: it changes only whitespace, every comment survives, and formatting twice is formatting once.
$ hek digest
Print what the program does with local names, layout, comments, file boundaries and declaration order taken out. Two versions that behave the same hash the same.
The editor grammar is a third crate, tree-sitter-hek, so an editor can load it without either of the others. hek fmt - formats a module from stdin, which is what format-on-save wants, and the two stay in step because hek fmt links that same grammar. There is no language server yet.
Read the specification.
Twenty-two documents, one per idea, each paired with a test file of the same name that is the same rules made executable. Commands is the best place to start, then effects for the rules a handler that reaches outside has to keep.
Writing heklang with an agent
The heklang repository ships a Claude skill: the whole language distilled into rules an agent can follow, one reference file per declaration kind, a table from every diagnostic code to its fix, and a worked example program. Copy .claude/skills/heklang/ into an application that writes .hk files and the agent working there picks it up.