Types and values
The complete type table, why equality is exact, and the one rule for what may be written where.
heklang’s type system is small and has no escape hatches. There is no null, no coercion, no subtyping, and no way to spell a value that is not one of the shapes below.
The types
Spellable anywhere a type is written:
| Type | |
|---|---|
Bool |
|
Int |
64-bit signed |
Decimal(n) |
a scaled decimal, n places, a rate |
Money(n) |
a scaled integer, n places, an amount |
String |
bounded with @max(n) where it is stored |
Uuid |
|
Timestamp |
UTC, RFC 3339 on the wire |
Json |
an opaque document, read with accessors |
List(T) |
|
Map(K, V) |
K must be a type that orders |
T? |
the only absence in the language |
| an enum name | |
| a record name |
Three types exist but cannot be written in a general type position, so none of them can
appear inside a List, a Map or a record:
| Type | Where it can appear |
|---|---|
Response |
a fn signature |
Outcome |
a fn signature |
Secret |
an effect-local fn signature |
Rounding is spellable nowhere at all. It reaches .mul and .div as the bare words
HalfUp, HalfEven and Down.
And one type is derived rather than written: Sealed(T, subject), produced by putting
@subject(...) on an event field. Opt is always outermost, so String? @subject(x) is
Opt(Sealed(String, x)). See Personal data.
Equality is exact and structural
Money(2) is not Decimal(2). Money(2) is not Money(3). Content sealed under one
subject is not content sealed under another.
There is no widening, no numeric promotion and no subtyping. An Int does not become a
Decimal(2) because the context wanted one.
The fill relation
One rule decides what may be written where. A value of type F fills a declared T when:
FisT; orTisF?, so a bare value wraps into an optional; orTisFsealed, so plaintext goes into a subject-bound field.
Nothing else. Two consequences catch people:
Wrapping is one level, at the outside. List(String) does not fill List(String?).
T? does not fill T. An optional has to be narrowed or unwrapped first.
The relation applies at every declared position: a command argument, an emit field, an
entity column, a fn parameter, a return, a record field.
Optionals
T? is the only absence. There is no null, no empty-string-means-nothing, and no
zero-means-nothing.
| Method | Returns |
|---|---|
unwrap_or(T) |
T |
is_some() |
Bool |
is_none() |
Bool |
There is no unwrap and no expect, because narrowing removes most of the calls you would
otherwise write:
if email.is_some() {
// email is a String here, not a String?
let clean = email.trim()
}
Four shapes deliberately do not narrow: a compound condition, the value-position if,
an else if, and an equality against a bare value. Each is sound in principle, and left out
to keep the rule three lines instead of a paragraph of exceptions.
Comparing an optional to a bare value works, because == and != lift the bare side.
Ordering one with < is an error.
Money
Money(n) is a distinct type from Decimal(n), and the operator table is the whole reason:
| Expression | Result |
|---|---|
Money(n) + Money(n) |
Money(n) |
Money(n) - Money(n) |
Money(n) |
Money(n) / Money(n) |
Decimal(6) |
Money(n) * Money(n) |
error |
Money(n) + Decimal(s) |
error |
amount.mul(rate, Rounding) |
Money(n) |
amount.div(Int, Rounding) |
Money(n) |
An amount times a rate is an amount. An amount over an amount is a rate. An amount plus a rate is a compile error, and an amount times an amount is meaningless.
.mul and .div are the only two places money rounds, and the author says how. The result
keeps the amount’s scale; the rate carries its own, so total.mul(0.9, HalfUp) takes a
Decimal(1).
Currency is not in the type, the value, or the configuration. A scale is a storage floor, not a currency, and scale caps at 18. If you need currency, declare an ordinary field beside the amount.
Literals
The lexer emits one untyped number per literal. There is no suffix and no constructor: a
literal becomes an Int, a Decimal(s) or a Money(n) depending on where it lands.
| Target | 10.5 |
10 |
|---|---|---|
Int |
error | 10 |
Decimal(2) |
10.50 |
10.00 |
Money(2) |
10.50 |
10.00 |
Money(0) |
error | 10 |
Widening is exact: a literal with more places than the target holds is an error rather than a silent round.
Where the target comes from is where the type comes from, and a literal with no target takes
its default: scale 0 is an Int, any other scale is a Decimal(n) at the scale written.
Nothing ever defaults to Money, so an amount is always reached from a declaration.
Strings
Interpolation is the whole mechanism:
log("order {order_id} for {customer_id}")
A hole holds an arbitrary expression, not just a name, because the lexer nests. \{ writes
a literal brace.
A raw string takes no escapes and no interpolation:
let body = """
{"kind": "raw", "braces": "kept"}
"""
There is no + on strings, no str(), no .to_string() and no format specifiers. A
value’s text form is its JSON form, and that table is fixed.
What is not checked
Being honest about the edges:
- anything whose type came out unknown, because one mistake should stay one diagnostic
- a
fnbody against its callers, beyond the signature - the shape of a
Json - currency
- two values sealed under different subjects, beyond that they differ