← All posts

Cure v0.15.0: Effects, Documentation, and Developer Experience

by Aleksei Matiushkin

release effects documentation repl formatter

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:

  1. Keywords. send and receive contribute :state, throw contributes :exception, spawn contributes :spawn.
  2. @extern targets. The target Erlang module is classified: :io and :file map to Io, :gen_server and :gen_statem to State, everything else to Extern.
  3. Transitive calls. Calling a function with effects E adds E to 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 --strict mode.
  • E009: Unreachable Clause – pattern clause shadowed by a prior clause.
  • E010: Missing Effect Annotation – warning in --strict-effects mode.

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:

  1. Infers the base expression type (e.g. {:named, "Point"})
  2. Looks up each override field in the registered schema
  3. Verifies the override value type is compatible with the declared field type
  4. 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.