Skip to content
hekla

Diagnostics

Every code the compiler can emit, what each one means, and what to do about it.

The diagnostic set is closed. Every rejection heklang can produce is one of the codes below, and the compiler enforces that: a variant it cannot reach is a category it claims to have and does not.

That closure is what makes recovering from a rejection a lookup rather than an invention, which matters as much for a person as it does for an agent.

Reading one

a.hk:2:40 [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

The header is file:line:col [code] message. Under it goes the source line with the extent drawn, then the hint, then every related location as a = note. Lines and columns are 1-based and the column counts characters.

Message and hint are separate on purpose. The message says what is wrong; the hint says what to do. A related note is a second place worth looking, like the first of two declarations sharing a name.

Three things about how they arrive

A syntax error abandons its declaration; a semantic one does not. A token the grammar cannot take means there is nothing left to read there, so the whole declaration is stepped over and the next one is parsed as if nothing happened. Everything else parsed, so the rest of the body is still checked.

A rejected value becomes a poison. Its type is unknown and an unknown type is never checked, so let x = text.trm() followed by two uses of x is one diagnostic rather than three. Fix the first and re-run.

Reporting stops at the end of the pass that found any, and the whole-program checks report one at a time. So a clean run after a fix can still surface something new. Keep running the checker until it says nothing.

Lexical and syntactic

These six abandon the declaration.

Code Means Fix
bad-number a numeric literal the scanner could not finish check for a stray . or a missing digit
unterminated-string a " or """ that ran to the end of the file close it; the position points at the opening quote
unknown-escape the set is \n \t \" \\ \{ \} use one of those, or a raw """ string
bad-path an @ with no name after it write the event path, @thing.happened
unexpected-character a character with no token in the language delete it
expected-token a token the grammar cannot take here the message names what was wanted

Names

Code Means Fix
declared-twice a name, field, variant, arm or annotation given twice names are global across files; the related note points at the first
not-declared spelled fine, declared nowhere declare it, or fix the spelling. Usually an event path
not-in-scope declared somewhere, not visible here an effect-local fn is visible only in its effect; an entity or projector enum only in its projector
unknown-member a field, method, parameter, variant or verb the receiver has not got check the standard library; the commonest pair is is_empty() asked of a String? and is_none() asked of a String
unknown-type a type name that names no type Response and Outcome are spellable only in a fn signature

Types and values

Code Means Fix
type-mismatch a value that does not fill a declared type T? does not fill T: use unwrap_or, or a branch that proves it present
bad-operands an operator applied to a pair it does not take scales never meet; Money and Decimal do not add; there is no + on String; an optional does not order, though == and != take one
bad-literal a literal that cannot be the type its position declares usually more decimal places than the target holds, or a malformed uuid or RFC 3339 string
bad-type a type spelled wrong a scale above 18, or a Map key that does not order
needs-target-type a value whose type nothing decides [], Map.empty, Money.parse and Decimal.parse take their type from the target; a let is not one
not-a-value a statement written where a value was wanted a call to a void effect-local fn is a statement, so it cannot be bound with let
arity a call with the wrong number of arguments a third positional argument to http.* is a timeout, which is configuration; reject Gone takes no braces
missing-field something that has to be given whole was not emit, put, given, invoke, a record literal, reject with fields and guard Name { .. } are all written whole
duplicate-field one given twice

Annotations and declaration shape

Code Means Fix
unknown-annotation an annotation that does not exist events take @subject, @max, @no_index; entities @key, @index, @max; records @max; enum variants @default; an effect arm’s trigger destructure @key
bad-annotation a known annotation in a place or shape it does not take @max bounds a String; a record field cannot be @subject; a @subject id may not be optional or itself sealed; an optional column may not default to none
empty-declaration a declaration whose body would be empty
arm-shape an effect arm with no @key every arm names the trigger field that identifies its lane
entity-shape no @key, more than one, an unorderable key, or an index on a field it has not got
event-shape a multi-path arm over event types with nothing in common a field is shared only when its type and its @subject match on every listed path
refusal-shape a refusal named or written so its derived code could not survive start with a capital, use no underscore, and let the message name every field and nothing else
stage-shape a fold or guard written where or how it does not go declarations come before the first statement of their stage, never inside an if or a for
no-zero-value a patch that would materialise a row it cannot fill give the column a default, make it optional, or make the write an update

Context and purity

Code Means Fix
wrong-context a statement in a declaration kind that does not have it see the matrix in the standard library; an on latest arm may not invoke
impure-fn a module fn doing something a pure function cannot move the call to the caller and pass the result in, or make it effect-local
fold-restriction a fold calling out, invoking, decrypting or reading a clock a fold has to reproduce without a journal; do it in the body and pass it in
arm-only an effect-local fn doing what stays in the arm reveal, erase, now() and fold stay in the arm; pass the value or the moment in
return-shape a return that does not match the signature it is in a guard returns only reject <Name> or invalid(...); a module fn must declare a return type and return on every path

Seals

Code Means Fix
seal-boundary sealed content leaving without reveal move it, ask .is_some(), or reveal it in an effect arm. A sealed field cannot be an arm’s @key either
erase-subject an erase whose subject or id is not one the inferring form takes a trigger field; the named form takes a declared subject and a value of the id’s type
erase-order a reveal reachable from an erase move the reveal above the erase, or into a branch the erase cannot reach. Inside a for body, any erase reaches every reveal
secret-boundary a credential reaching something that observes it send it instead: a url, a header value, a body member. log, fail, emit, invoke, a comparison and every method are all out

Whole-program

These are checked after the passes and report one at a time.

Code Means Fix
recursive-fn a fn that calls itself, directly or through a chain the error names the cycle; rewrite as a for over a finite container
recursive-guard a guard that names itself a guard is copied into what names it, so a cycle has no end
max-tightening a bounded position tighter than a field written into it widen the target’s @max, or narrow the source’s. A computed value is not this defect
self-trigger an effect that can trigger itself through invoked commands break the cycle, or have the command refuse a replay so the chain ends
const-cycle a const that names itself the error names the chain

Tests

Code Means Fix
test-shape a test body out of order, or an expectation its action cannot produce order is given, then respond/erased, then exactly one action, then expect

What still reaches run time

One thing is not statically decidable, and it is worth knowing which:

Arithmetic that cannot be answered exactly. Money(n) / Int and Money(n) * Decimal(s) type-check and then fail at run time when the result is not exact, naming div or mul. That is data-dependent, so no static pass can cover it. Use .mul(rate, Rounding) and .div(n, Rounding) where you want a rounding rule rather than a failure.

This is why a green check is necessary and not sufficient, and why the tests are worth writing.