Skip to content
hekla

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

orders.hk
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 }
}
01Five kinds of declaration

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.

commanddecides and appendsReplays the history its decision depends on, then appends events. The only declaration that writes to the log, and the only one that may emit.
projectorbuilds a read modelA pure fold over the log into rows. No clock, no network, no failure channel, and no general read of its own rows, which is what makes a rebuild reproduce them exactly.
effectreaches the worldReacts to appended events with durable side effects. The only declaration that touches the network, and the only one that may decrypt or erase.
guarda shared propositionA named claim about the log with one refusal, which several commands can compose. Named for what it asserts, not for the entity it reads.
refusala named noOne home for a reason a command said no. The wire code is the name in snake_case, so the message is settled once rather than at every site that answers with it.
02Types

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.

03Diagnostics

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.

hek check
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
04Two handlers

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.

projectors/customer-orders.hkpure, and therefore rebuildable
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 }
  }
}
effects/notify-customer.hkimpure, and therefore journaled
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}")
    }
  }
}
05Deliberately absent

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
06One binary

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.

07Next

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.