Skip to content
hekla

Functions, records and constants

The declarations that exist so a rule is written once, and the two places purity is enforced.

Functions

A module-level fn is pure. It takes typed parameters, declares a return type, and every path returns:

fn line_total(unit: Money(2), quantity: Int) -> Money(2) {
  return unit.mul(quantity, HalfUp)
}

Purity is the whole of what makes it callable from a command, a projector, an effect arm and a fold arm alike. A function that could read a clock would be a function a projector could not use.

A fn may decide a refusal by returning an Outcome?, where none means no objection:

fn check_limit(total: Money(2)) -> Outcome? {
  if total > MAX_ORDER {
    reject TooLarge
  }
  return none
}

A bare -> Outcome is legal too, when every path is a reject or an invalid. It simply cannot express the no-objection path.

Response may be a parameter type, which is how response handling is shared between arms.

Effect-local functions

An fn declared inside an effect may call out, and may return nothing:

effect ConfirmBooking {
  fn notify(url: String, body: Json) {
    let response = http.post(url, body)
    if response.status >= 400 {
      log("notify failed with {response.status}")
    }
  }
}

It may not reveal, erase or read the clock with now(), it declares no fold of its own, and it cannot be called from a fold arm. Keeping reveal and erase in the arm is what lets the checker prove erasure happens last, and now() is pinned once per invocation, so read it in the arm and pass the moment in.

A fn may return an event

An event is a record rather than a value. Making one is pure, so a module fn may hand one back:

// One log line, three cases. A `fn` may return an event, so the shape every case needs is
// written once and the block after a call says what this one is about.
fn an_order(order_id: Uuid, total: Money(2)) -> @order.placed {
  return @order.placed {
    order_id,
    customer_id: 7,
    email: "ada@example.com",
    ship_to: Address { line1: "1 High St", city: "Reykjavik", country: "IS" },
    total,
  }
}

@order.placed is spellable in a module fn’s return type and nowhere else: not a parameter, not inside a List or a Map, not an entity column, and not an effect-local fn, whose result no test could reach. The literal is written after a return and nowhere else, so nothing holds an event in a let. What consumes one is a test’s given and expect. See Tests.

What is rejected

Recursion, statically, with the cycle named. No closures, no generics, no overloading, and no default or named arguments.

Records

A record is a product type declared at module scope:

record Address {
  line1: String @max(120),
  city: String @max(80),
  postcode: String @max(16),
}

Every field must be given when one is constructed. There is no partial record.

Two deliberate absences:

No record update. There is no base with { city: "Reykjavik" }. Building the new value explicitly keeps each write to the aspect the event carries, so nothing read-modify-writes a record it did not build.

@subject is illegal on a record field, and it is a dedicated error rather than a generic one. The annotation names a sibling field holding the id, and a field inside a record has no sibling to name.

That is narrower than it sounds. A record still carries personal data, because @subject on an event field whose type is a record seals the whole record: ship_to: Address @subject(customer_id) is one sealed field rather than nine, reveal hands back an Address, and @absent keeps working inside it, because a seal is read as stored history rather than as a request body. See Personal data.

Enums

enum Status {
  @default Pending,
  Confirmed,
  Cancelled,
}

The @default variant is the zero value, which is what makes an enum column usable with patch. A projector may declare its own enum, and a local one shadows a module-level one of the same name.

Variants are written bare where the type is known: status: Confirmed.

Constants

// How many orders a shop may take during the launch. Small enough that the example's
// tests can reach it.
const LAUNCH_ALLOCATION: Int = 3

A const holds a literal or a literal aggregate, and may name another const. A cycle between them is a compile error naming the cycle.

An optional const is either none or a value.

Constants are inlined, so a change to one is a change to every declaration that reads it, and hekla plan will report those declarations as changed.

Secrets

A secret is a soft word, and the declaration carries no type and no value:

secret DISCORD_WEBHOOK
secret SENTRY_DSN?

The trailing ? marks it optional, so serving starts without it.

The value arrives at deployment, never from the source tree. Its type is Secret, which is a taint: it can be handed to an outbound call and cannot be interpolated into a log line, a refusal message or a fail. See Keys and secrets.

A const fills a position by its declared type rather than by the value it holds, and that is load-bearing rather than a technicality: const HOUSE: Customer = 1 fills a Customer and const LIMIT: Int = 1 does not, though both inline to the same 1. It is also the only way to write a named subject id.

Subjects

A subject is a soft word too, and the declaration names a key namespace and the type its ids are:

subject Shop(Int)
subject Customer(Int) under Shop

The name becomes a type whose values are that namespace’s ids, and under says whose keys this one’s are wrapped inside. Unlike everything else on this page a subject is global rather than module-scoped, because a module-local key namespace would mean one person with two spellings. See Personal data.

Modules

A file is a module, and a module is not a namespace. There is no import syntax, no manifest and no header item; every .hk file under the project root is part of one program.

Six kinds get their own name space, so a command and a projector may share a name: command, projector, effect, fn, guard, refusal.