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 type variables (bare capitalized identifiers):
fn identity(x: T) -> T = x
fn apply(f: A -> B, x: A) -> B = f(x)
Keywords
Reserved words in Cure:
fn, mod, rec, fsm, proto, impl, type, let, if, then, else, elif, match, when, where, local, use, return, throw, try, catch, finally, for, in, true, false, nil, and, or, not
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:
# Pipe chains
5 |> double |> add(1)
# desugars to: add(double(5), 1)
# Boolean
x > 0 and x < 100 or x == -1
# String concat
"hello" <> " " <> "world"
# Range
1..10
0..=255
# Field access
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
Nil
nil
Chars
Single-quoted:
'a'
'Z'
Lists
[1, 2, 3]
["a", "b", "c"]
[]
Cons syntax for head/tail decomposition:
[h | t]
Since v0.19.0, multi-head cons patterns desugar to right-associated cons cells and work in both pattern and construction position:
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.
If / then / else
if is an expression and always produces a value:
fn abs(x: Int) -> Int = if x > 0 then x else 0 - x
fn sign(x: Int) -> String =
if x > 0 then "positive"
elif x < 0 then "negative"
else "zero"
Both branches must be present when the result is used. elif chains multiple conditions.
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 must use empty parentheses (None()); a bare
None would bind a fresh variable.
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 =
if a.age > b.age then a else b
fn greet(p: Person) -> String = "Hello, " <> p.name
Protocols
Protocols provide ad-hoc polymorphism (similar to type classes or interfaces). Define with proto, implement with impl:
proto Show(T)
fn show(x: T) -> String
impl Show for Int
fn show(x: Int) -> String = Std.String.from_int(x)
impl Show for Bool
fn show(x: Bool) -> String = if x then "true" else "false"
impl Show for String
fn show(x: String) -> String = x
Protocol dispatch compiles to guard-based multi-clause BEAM functions.
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 #{}.
Refinement types
Constrain a base type with a logical predicate:
type NonZero = {x: Int | x != 0}
type Positive = {x: Int | x > 0}
type Percentage = {p: Int | p >= 0 and p <= 100}
Functions can use when guards that are verified at call sites via Z3:
fn safe_divide(a: Int, b: Int) -> Int when b != 0 = a / b
fn positive_double(x: Int) -> Int when x > 0 = x * 2
See the Type System page for details on how refinement types and dependent type verification work.
FSMs (Finite State Machines)
FSMs are first-class language constructs:
fsm TrafficLight
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)