Language Guide
Cure is an indentation-structured, expression-oriented language that compiles to BEAM bytecode. Blocks are delimited by indentation level -- no do/end, no braces. The last expression in a block is its value.
Modules
Every Cure source file contains one module. The module name follows Elixir/Erlang dot-separated conventions:
mod MyApp.Math
fn add(a: Int, b: Int) -> Int = a + b
fn sub(a: Int, b: Int) -> Int = a - b
All functions inside a module are public by default. Use local fn for private functions:
mod MyApp.Internal
fn public_api(x: Int) -> Int = helper(x) + 1
local fn helper(x: Int) -> Int = x * 2
Functions
Single-expression body
When the body is a single expression, write it after = on the same line:
fn add(a: Int, b: Int) -> Int = a + b
fn greet(name: String) -> String = "Hello, " <> name <> "!"
fn identity(x: T) -> T = x
Multi-expression body
For multiple expressions, put = at the end of the signature line, then indent the body:
fn compute(x: Int) -> Int =
let y = x * 2
let z = y + 1
z
The last expression (z) is the return value.
Multi-clause functions
Pattern match on arguments using | clauses:
fn factorial(n: Int) -> Int
| 0 -> 1
| n -> n * factorial(n - 1)
fn describe(x: Int) -> String
| 0 -> "zero"
| 1 -> "one"
| _ -> "other"
fn fibonacci(n: Int) -> Int
| 0 -> 0
| 1 -> 1
| n -> fibonacci(n - 1) + fibonacci(n - 2)
Guards
Guards restrict when a function clause or pattern applies. Use when after the parameter list or after the pattern:
fn abs(x: Int) -> Int when x >= 0 = x
fn classify(x: Int) -> String
| x when x > 0 -> "positive"
| x when x < 0 -> "negative"
| _ -> "zero"
Guards can use comparison operators (>, <, >=, <=, ==, !=), boolean operators (and, or, not), and arithmetic.
Effect annotations
Functions can declare their side effects after the return type using !:
fn read_file(path: String) -> String ! Io
fn risky(x: Int) -> Int ! Exception
fn complex(x: Int) -> Int ! Io, Exception
Effect kinds: Io, State, Exception, Spawn, Extern. When no ! annotation is present, effects are inferred from the body.
Type annotations
Every parameter must have a type annotation. Return types are declared after ->:
fn process(name: String, count: Int) -> String = name <> "!"
Polymorphic functions use lowercase type variables:
fn identity(x: t) -> t = x
fn apply(f: a -> b, x: a) -> b = f(x)
Keywords
The current declaration and expression vocabulary includes:
fn, mod, rec, fsm, actor, interface, implementation, type,
typealias, primitive, let, pickup, else, match, with, when,
local, use, return, throw, try, catch, finally, for, in,
true, false, nil, and, or, not, quote, and unsafe.
Earlier-edition words such as proto, impl, if, then, and elif
remain recognizable so cure migrate can rewrite them.
Comments
Single-line comments start with #:
# This is a comment
fn add(a: Int, b: Int) -> Int = a + b # inline comment
Doc comments start with ## and are attached to the following definition. They are extracted by cure doc to generate HTML documentation:
## Returns the absolute value of an integer.
##
## Examples:
## abs(-5) # => 5
fn abs(x: Int) -> Int
| x when x >= 0 -> x
| x -> -x
Operators
Ordered from lowest to highest precedence:
| Precedence | Operator(s) | Associativity | Description |
|---|---|---|---|
| 1 | |> |
left | pipe |
| 2 | or |
left | boolean or |
| 3 | and |
left | boolean and |
| 4 | == != < > <= >= |
non-assoc | comparison |
| 5 | .. ..= |
non-assoc | range (exclusive, inclusive) |
| 6 | <> |
right | string concatenation |
| 7 | + - |
left | additive |
| 8 | * / % |
left | multiplicative |
| 9 | - not |
prefix | unary negation, boolean not |
| 10 | . |
left | field access |
Examples:
fn double(x: Int) -> Int = x * 2
fn add(a: Int, b: Int) -> Int = a + b
fn pipe_example() -> Int =
5 |> double |> add(1)
# Pipe chains
# desugars to: add(double(5), 1)
# Boolean
fn bounded(x: Int) -> Bool = x > 0 and x < 100 or x == -1
# String concat
fn greeting() -> String = "hello" <> " " <> "world"
# Range
fn range_example() -> List(Int) = [1, 2, 3]
# Field access
rec Point
x: Int
y: Int
fn point_sum(point: Point) -> Int = point.x + point.y
Literals
Integers
42
0xFF
0b1010
1_000_000
Floats
3.14
0.001
Strings
Double-quoted, with interpolation via #{}:
"hello"
"hello #{name}"
"result: #{compute(42)}"
Booleans
true
false
Atoms
Prefixed with ::
:ok
:error
:my_atom
Empty list
[]
Chars
Single-quoted:
'a'
'Z'
Lists
[1, 2, 3]
["a", "b", "c"]
[]
Cons syntax for head/tail decomposition:
fn first(xs: List(Int)) -> Int =
match xs
[h | _t] -> h
[] -> 0
Since v0.19.0, multi-head cons patterns desugar to right-associated cons cells and work in both pattern and construction position:
fn first_three(xs: List(Int)) -> Int =
match xs
[a, b, c | _rest] -> a + b + c
_ -> 0
is equivalent to [a | [b | [c | rest]]].
Tuples
Prefixed with %:
%[1, "hello"]
%[x, y, z]
Maps
Prefixed with %:
%{name: "Alice", age: 30}
%{key: value}
Binary literals and bitstring segments
Since v0.20.0, binary literals use the full Elixir-style segment
grammar. Each element inside <<...>> may carry type, size,
endianness, signedness, and unit specifiers, chained with -:
<<tag::utf8, size::16, payload::binary-size(size), rest::binary>>
:: introduces the specifier chain; type atoms are integer,
float, bits, bitstring, bytes, binary, utf8, utf16,
utf32; big / little / native select the endianness;
signed / unsigned the signedness; size(n) and unit(u) the
width. A bare integer is shorthand for size(n):
<<x::8>> # same as <<x::size(8)>>
<<x::32-signed>> # 32-bit signed big-endian integer
<<x::float-little>> # 64-bit little-endian float
Defaults mirror Erlang:
integer-unsigned-big-size(8)-unit(1), with utf8 / utf16 /
utf32 providing their own implicit size. The same segment
grammar works in pattern position.
Let bindings
Introduce local variables with let:
fn compute(x: Int) -> Int =
let doubled = x * 2
let offset = 10
doubled + offset
let bindings are immutable. Each let introduces a new binding; there is no reassignment.
Conditional dispatch
pickup is the canonical conditional expression:
fn abs(x: Int) -> Int =
pickup
x > 0 -> x
else -> 0 - x
fn sign(x: Int) -> String =
pickup
x > 0 -> "positive"
x < 0 -> "negative"
else -> "zero"
Conditions are tested top-to-bottom and else is the fallback.
Match expressions
Pattern match on values with match. Since v0.18.0 patterns
destructure arbitrary nesting across tuples, lists (cons and fixed),
maps, records, and ADT constructors.
ADT constructors and cons
fn unwrap(opt: Option(Int)) -> Int =
match opt
Some(v) -> v
None -> 0
fn describe_list(xs: List(Int)) -> String =
match xs
[] -> "empty"
[h | t] -> "starts with " <> Std.String.from_int(h)
fn handle(r: Result(Int, String)) -> Int =
match r
Ok(v) -> v
Error(_) -> -1
Nullary constructors may be bare (None) or use empty parentheses (None()).
Bare PascalCase names resolve against the scrutinee type's constructors;
lowercase bare names remain variable bindings.
Records and field punning
match person
Person{name, age} -> salute(name, age)
Person{name, address: Address{city}} -> greet(name, city)
A bare identifier inside a record pattern is shorthand for
name: name (field punning). Record patterns compile to map patterns
with a __struct__ := :tag guard, so they only match values built
with the same record type.
Maps
match request
%{method: "GET", path: p} -> fetch(p)
%{method: m, path: _} -> reject(m)
Map keys in patterns must be literal. Open matching: unmentioned keys are ignored.
Tuples and nested destructuring
Any combination of the above nests:
match value
%[_, %{list: [head | tail]}, _] -> handle(head, tail)
%[Ok(v), Error(_)] -> v
_ -> default
The pin operator ^x
^x compares against an already-bound variable rather than binding
fresh. The compiler lowers it to a synthetic equality guard.
let target = get_tag()
match event.tag
^target -> :hit
_ -> :miss
Repeated variables
A name that occurs more than once in the same pattern must match the same value at every position:
match pair
%[x, x] -> :equal
_ -> :different
Exhaustiveness
The compiler checks pattern exhaustiveness. Shallow coverage gaps are
reported as E004; nested gaps in tuple scrutinees (e.g. %[Ok(_)]
but no %[Error(_)]) are reported as E025 with a concrete missing
witness.
See the dedicated Patterns reference for the full AST-to-Erlang mapping.
Pipe operator
The pipe operator |> passes the result of the left expression as the first argument to the function on the right:
fn process(xs: List(Int)) -> Int =
xs
|> Std.List.filter(fn(x) -> x > 0)
|> Std.List.map(fn(x) -> x * 2)
|> Std.List.sum()
ADTs (algebraic data types)
Define sum types with type:
type Color = Red | Green | Blue
type Option(T) = Some(T) | None
type Result(T, E) = Ok(T) | Error(E)
type Shape = Circle(Float) | Rectangle(Float, Float) | Point
Use constructors as regular functions:
fn wrap(x: Int) -> Option(Int) = Some(x)
fn nothing() -> Option(Int) = None()
fn make_color() -> Color = Red()
fn safe_divide(a: Int, b: Int) -> Result(Int, String) =
if b == 0 then Error("division by zero") else Ok(a / b)
Destructure ADTs with match:
fn unwrap_or(opt: Option(Int), default: Int) -> Int =
match opt
Some(v) -> v
None() -> default
Records
Records are named product types. They compile to BEAM maps and are
fully type-checked: the compiler tracks field names and types for
each rec definition.
Definition
rec Point
x: Int
y: Int
rec Person
name: String
age: Int
rec Rectangle
origin: Point
width: Int
height: Int
All field types must be named. Any is accepted as an escape hatch but
forfeits field-level type checking for that field.
Parameterized records
Records can take type parameters:
rec Pair(A, B)
first: A
second: B
Type parameters are erased at runtime but used by the type checker.
Construction
Use TypeName{field: expr, ...} to build a record value:
fn make_point(x: Int, y: Int) -> Point = Point{x: x, y: y}
fn origin() -> Point = Point{x: 0, y: 0}
fn make_person(name: String, age: Int) -> Person =
Person{name: name, age: age}
fn make_pair(a: Any, b: Any) -> Pair(Any, Any) = Pair{first: a, second: b}
Fields can appear in any order. The type checker verifies each value type against the declared field type.
Field access
Dot notation record.field looks up a field at runtime via maps:get/2:
fn x_coord(p: Point) -> Int = p.x
fn y_coord(p: Point) -> Int = p.y
fn person_name(p: Person) -> String = p.name
fn area(r: Rectangle) -> Int = r.width * r.height
Nested access chains multiple . operations:
fn rect_origin_x(r: Rectangle) -> Int = r.origin.x
Record update
Produce a modified copy using TypeName{base | field: val, ...}.
Only the listed fields change; all others are preserved unchanged:
# Single-field update
fn set_x(p: Point, new_x: Int) -> Point = Point{p | x: new_x}
fn birthday(p: Person) -> Person = Person{p | age: p.age + 1}
# Multi-field update
fn translate(p: Point, dx: Int, dy: Int) -> Point =
Point{p | x: p.x + dx, y: p.y + dy}
fn move(p: Point, nx: Int, ny: Int) -> Point =
Point{p | x: nx, y: ny}
The type name before { is required. The base expression must have the same
record type. The compiler checks each override value against its declared
field type and returns the same named type.
Record update compiles to the BEAM map-update instruction (Map#{key := val}),
which copies the map and overwrites only the specified keys. The __struct__
field is preserved automatically.
Records in computations
fn distance_squared(a: Point, b: Point) -> Int =
let dx = b.x - a.x
let dy = b.y - a.y
dx * dx + dy * dy
fn midpoint(a: Point, b: Point) -> Point =
Point{x: (a.x + b.x) / 2, y: (a.y + b.y) / 2}
fn older_of(a: Person, b: Person) -> Person =
pickup
a.age > b.age -> a
else -> b
fn greet(p: Person) -> String = "Hello, " <> p.name
Interfaces
Interfaces provide ad-hoc polymorphism. Define with interface, implement
with implementation, and state generic obligations with requires:
interface Show(t)
fn show(x: t) -> String
implementation Show for Int
fn show(x: Int) -> String = Std.String.from_int(x)
implementation Show for Bool
fn show(x: Bool) -> String =
pickup
x -> "true"
else -> "false"
implementation Show for String
fn show(x: String) -> String = x
fn display(x: t) -> String requires Show(t) = show(x)
Resolution is compile-time and uses canonical interface and implementation identities. See the dedicated Interfaces guide.
Imports
Import modules with use:
mod MyApp
use Std.List
use Std.Core
fn double_all(xs: List(Int)) -> List(Int) =
Std.List.map(xs, fn(x) -> x * 2)
Import multiple modules from the same namespace:
use Std.{List, Core, Math}
FFI (Foreign Function Interface)
Call Erlang/OTP functions with the @extern attribute:
@extern(:erlang, :abs, 1)
fn abs(x: Int) -> Int
@extern(:math, :sqrt, 1)
fn sqrt(x: Float) -> Float
@extern(:erlang, :integer_to_binary, 1)
fn int_to_string(n: Int) -> String
@extern(:io, :put_chars, 1)
fn print(s: String) -> Atom
The three arguments are the Erlang module atom, the function atom, and the arity. The compiler generates a wrapper that delegates to the Erlang function.
Lambdas
Anonymous functions use fn without a name:
fn double_all(xs: List(Int)) -> List(Int) =
Std.List.map(xs, fn(x) -> x * 2)
fn apply_twice(f: Int -> Int, x: Int) -> Int = f(f(x))
fn make_adder(n: Int) -> Int -> Int = fn(x) -> x + n
Lambdas with multiple arguments:
Std.List.foldl(xs, 0, fn(x) -> fn(acc) -> acc + x)
Note: curried style -- each fn takes one argument and returns the next function.
String interpolation
Embed expressions inside strings with #{}:
fn greet(name: String, age: Int) -> String =
"Hello, #{name}! You are #{Std.String.from_int(age)} years old."
Any expression can appear inside #{}.
Invariants and dependent types
The {x: t | predicate} refinement former elaborates to a dependent pair
(Sigma(t, predicate)) that the kernel checks like any other type. A value
whose obligation reduces to a closed, true proposition is accepted with no
explicit proof term; an obligation that depends on a bound parameter is
discharged by proof search over in-scope hypotheses and @lemma-tagged
theorems. Guards still narrow branches and drive coverage diagnostics; the
separate Z3-backed GuardLint checks guard coverage and shadowing as a lint
outside the trusted kernel and plays no part in refinement discharge.
See the Type System page for indexed families, quantitative binders, and kernel-checked equality.
User-defined syntax
Macros declare surface grammar with syntax ... becomes. Holes are typed,
repeatable, and hygienic:
syntax beam_ops tell <dest: Code> <message: Code>
becomes Std.Otp.tell(dest, message)
quote builds syntax and $(...) splices values into it:
let ast = quote %[:ok, $(payload)]
computed by invokes a typed elaborator for expansions that cannot be
expressed as a template. Std.Syntax provides lossless reflection. Expanded
declarations keep invocation and definition provenance and enter the same
canonical module interface as authored declarations.
Editions and migration
@edition and [project].edition select a project's grammar. Use
cure migrate to cross editions:
cure migrate --check src
cure migrate --print src/old.cure
cure migrate --strict src
Migration rewrites retired keywords, conditional syntax, type-variable casing, decorator placement, and renamed modules to the current surface.
FSMs (Finite State Machines)
fsm is a macro from Std.Fsm, brought into scope with use Std.Fsm. A
transition table derives its state and event types from the rows themselves;
states and events are capitalised constructors.
use Std.Fsm
fsm TrafficLight with Int
Red --Timer--> Green
Green --Timer--> Yellow
Yellow --Timer--> Red
* --Emergency--> Red
See the Finite State Machines page for the full guide.
Comments
Line comments start with #:
# This is a comment
fn add(a: Int, b: Int) -> Int = a + b # inline comment
Complete example
mod MyApp.Math
use Std.{Result, Option}
type Sign = Positive | Negative | Zero
fn factorial(n: Int) -> Int
| 0 -> 1
| n -> n * factorial(n - 1)
fn classify(x: Int) -> Sign
| x when x > 0 -> Positive
| x when x < 0 -> Negative
| _ -> Zero
fn safe_divide(a: Int, b: {x: Int | x != 0}) -> Int = a / b
fn sum(xs: List(Int)) -> Int =
Std.List.foldl(xs, 0, fn(x) -> fn(acc) -> acc + x)
fn main() -> Int = factorial(10)