Cure v0.15.0: Effects, Documentation, and Developer Experience
by Aleksei Matiushkin
v0.14 made Cure usable. v0.15 makes it principled.
The central question of this release: what does a function do? Not just what types go in and come out, but what side effects it performs. The effect system answers this at compile time. The documentation generator makes the answer browsable. And three new CLI commands make the daily workflow faster.
Effect system
Functions in Cure can now declare their side effects:
fn read_file(path: String) -> String ! Io
fn risky(x: Int) -> Int ! Exception
fn complex(x: Int) -> Int ! Io, Exception
The ! token after the return type introduces an effect annotation. Five
effect kinds are tracked: Io (printing, file access), State (send,
receive, process dictionary), Exception (throw), Spawn (process
creation), and Extern (unclassified foreign calls).
Effect annotations are optional. When omitted, the type checker infers effects from the function body. When present, the checker verifies the declared effects against the inferred ones – undeclared effects produce an error (E006), over-declared effects produce a warning.
How inference works
Cure.Types.Effects walks the function body AST and collects effects from
three sources:
-
Keywords.
sendandreceivecontribute:state,throwcontributes:exception,spawncontributes:spawn. -
@externtargets. The target Erlang module is classified::ioand:filemap toIo,:gen_serverand:gen_statemtoState, everything else toExtern. -
Transitive calls. Calling a function with effects
EaddsEto the caller. The type environment stores inferred effects for each function, so transitive propagation works across the module.
Effect subtyping
A pure function (no effects) is a subtype of any effectful function with the same signature. This means you can pass a pure callback where an effectful one is expected – the Liskov substitution principle, applied to effects:
{:fun, [Int], Int} <: {:effun, [Int], Int, MapSet.new([:io])}
The reverse is not true: an effectful function is NOT a subtype of a pure function (unless its effect set is empty).
Optimizer integration
The Inline optimizer pass previously used an ad-hoc list of function names
to determine purity (println, throw, spawn, …). It now uses
Type.pure?/1, which checks whether a function’s type has an empty effect
set. This grounds the optimizer’s purity notion in the type system.
LSP hover
Hovering over a function in the LSP now shows inferred effects alongside the
signature. If a function calls println, the hover tooltip includes
Effects: Io.
Pipeline events
Two new event types:
-
{:type_checker, :effects_inferred, {fn_name, effects}, meta}– emitted after every function body is checked. -
{:type_checker, :effect_error, error, meta}– emitted when declared effects conflict with inferred effects.
Documentation generator
Cure programs can now have doc comments:
mod Std.Math
## Returns the absolute value of an integer.
##
## Examples:
## abs(-5) # => 5
fn abs(x: Int) -> Int
| x when x >= 0 -> x
| x -> -x
Double hash (##) introduces a doc comment. Single hash (#) remains a
regular comment, discarded by the lexer. The parser collects consecutive
## lines and attaches them as :doc metadata on the following definition.
cure doc
The new CLI command generates static HTML documentation:
cure doc # documents all .cure files in lib/
cure doc lib/std/ --output-dir docs/ --title "Cure Stdlib"
The generator produces one HTML page per module plus an index, with:
- Syntax-highlighted function signatures
- Effect badges (Io, State, Exception, …)
- Extern/guarded/multi-clause indicators
-
Dark/light mode via
prefers-color-scheme - Module-level and function-level doc text
Cure.Doc.Extractor handles AST-to-structured-map extraction.
Cure.Doc.HTMLGenerator handles rendering. No external template
dependencies.
Stdlib documented
All 18 stdlib modules now have ## module-level doc comments. The Std.Io
module also has per-function docs and effect annotations:
mod Std.Io
## Standard I/O operations for printing to stdout.
## Print a string to stdout without trailing newline.
@extern(:io, :put_chars, 1)
fn put_chars(text: String) -> Atom ! Io
Developer experience
cure repl
A minimal interactive session:
$ cure repl
Cure REPL v0.15.0 (type :quit to exit)
cure(1)> 2 + 3
5
cure(2)> "hello" <> " world"
"hello world"
cure(3)> :quit
Bye.
Each expression is wrapped in a synthetic module, compiled via
compile_and_load, and its main/0 result printed. No persistent state
across expressions (that is for a future version), but it is enough for
quick experimentation.
cure fmt
A source formatter that parses .cure files and reprints them using
Cure.Compiler.Printer:
cure fmt # formats all .cure files in lib/ and test/
cure fmt lib/std/core.cure # format a specific file
The printer already existed and supports the full language. Wrapping it in a CLI command was ~20 lines.
Error catalog expansion
Five new error codes (E006-E010):
- E006: Effect Violation – calling an effectful function from a declared-pure context.
-
E007: Unused Variable – defined but never referenced (suppress with
_prefix). -
E008: Undocumented Public Function – warning in
cure doc --strictmode. - E009: Unreachable Clause – pattern clause shadowed by a prior clause.
-
E010: Missing Effect Annotation – warning in
--strict-effectsmode.
Unused variable tracking
Cure.Types.Env now tracks which variables are looked up via a used
MapSet. Env.unused_variables/1 returns all variables that were extended
into scope but never referenced (excluding _-prefixed names and builtins).
This is the foundation for E007 warnings.
Records
Record types are now fully type-checked. Three features landed together:
Typed field access
Previously, p.x where p : Point always inferred Any, causing spurious
type mismatch errors whenever a function declared a specific return type.
The type checker now registers each rec definition’s field schema during
its first pass and looks up field types during inference:
rec Point
x: Int
y: Int
fn x_coord(p: Point) -> Int = p.x # correctly infers Int
fn distance_squared(a: Point, b: Point) -> Int =
let dx = b.x - a.x
let dy = b.y - a.y
dx * dx + dy * dy # all arithmetic on Int, return type matches
The root cause was that Type.resolve/1 returned :any for any unknown
uppercase name. It now returns {:named, "TypeName"} – a lightweight
reference that carries the name through the type checker without losing it.
Record update syntax
Produce a modified copy of a record with TypeName{base | field: val, ...}:
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}
fn translate(p: Point, dx: Int, dy: Int) -> Point =
Point{p | x: p.x + dx, y: p.y + dy}
Only the listed fields change; all others (including __struct__) are
preserved. This compiles to the BEAM map-update instruction (Map#{key := val}),
which is a single efficient O(n) copy where n is the number of changed fields.
The parser detects update vs. construction by probing: after consuming {,
it parses one expression and checks whether the next token is |. If so,
it commits to update mode; otherwise it rewinds and parses as a normal
field-value construction.
Type checker integration
For update expressions, the type checker:
-
Infers the base expression type (e.g.
{:named, "Point"}) - Looks up each override field in the registered schema
- Verifies the override value type is compatible with the declared field type
- Returns the same named type as the base
A wrong field type is a compile-time error:
# error: field 'x' expects Int but update value has type String
fn bad(p: Point) -> Point = Point{p | x: "not an int"}
The numbers
678 tests (all passing). Zero compilation warnings. Zero credo issues. 54 Elixir source files. 18 stdlib modules. ~210 exported functions.
~700 lines of new code across 35 files (32 modified, 3 new modules).
New files
-
lib/cure/types/effects.ex– effect inference and checking -
lib/cure/doc/extractor.ex– AST-to-doc-map extraction -
lib/cure/doc/html_generator.ex– HTML rendering with embedded CSS
Getting started
git clone https://github.com/Oeditus/cure.git
cd cure
mix deps.get && mix test
mix escript.build
./cure version # Cure 0.15.0
./cure repl # interactive session
./cure doc lib/std/ # generate stdlib docs
The repository is at github.com/Oeditus/cure.