Language guide
The whole language, in the order you'll need it. Every example on this page is valid Candor. If you're a model, or briefing one, llms.txt has the same content in a denser form.
- Files, lines and blocks
- Modules
- Types
- let, var and constants
- Control flow
- Functions
- Contracts and examples
- Effects
- Typed holes
- Records
- Enums and match
- Distinct types
- Value semantics
- inout and borrowing
- Built-ins
- Traps
- The CLI
- Reading diagnostics
Files, lines and blocks
A .cnd file holds top-level type, fn and constant (let) declarations. One statement per line; a newline ends a statement, except inside brackets or after an operator or comma, so long expressions can wrap after + or ,. Comments start with #.
Every block ends with a labeled end: end fn name, end type Name, end if, end while, end for, end match. If the label doesn't match the open block, the error names the line where the block was opened.
Modules
# app.cnd
use "lib/shapes.cnd"
use "lib/geometry.cnd"
fn main()
effects io
print(show(area(Square(2.0))))
end fn main
use "path.cnd"goes at top level. The path is relative to the file that contains theuse. Each file is loaded once, even if it's reached by several paths, and cycles between files are fine.- Names stay globally unique. There are no namespaces or qualified names, so every name means one thing across the whole program. A duplicate in another file is reported with the location of the first definition.
- Visibility is direct. A file can name a function, type, variant or constant only if it declares it itself or if a file it directly
uses does. Using a value whose type comes from elsewhere, such as reading a field, needs no import. Every name you write can be traced to ausein the same file.
error[not-imported] app.cnd:10:11
'deep_value' is defined in lib/deep.cnd, which this file does not use
hint: add 'use "lib/deep.cnd"' at the top of this file
Diagnostics, hole reports and traps all name the real file: trap in ratio at geometry.cnd:15: division by zero.
Types
| Type | Meaning |
|---|---|
Int | Integer in ±(253−1). Overflow traps instead of wrapping. / truncates toward zero. |
Float | IEEE-754 double. show prints the shortest form that round-trips: 0.1 + 0.2 shows as 0.30000000000000004. |
Bool | true / false. Nothing else is truthy. |
Str | Immutable UTF-8 bytes. len, byte_at and slice work on bytes. |
Unit | The "no value" type: what a function without -> T returns. |
[T] | Growable array of T. |
| records, enums | Declared with type; see records and enums. |
== and != are structural: they work on arrays and records made of comparable values, so split("a b") == ["a", "b"] is true. Enums with payloads can't be compared; use match.
There are no implicit conversions. 1 + 2.0 is an error with a hint to use to_float. Strings are joined with ++, and other values become strings with show.
let, var and constants
let LIMIT: Int = 100 # top-level constant: type required
fn demo()
let name = "Ada" # immutable
var count = 0 # mutable
count = count + 1
var xs: [Int] = [] # empty literals need a type
end fn demo
- No shadowing. A name can't be redeclared anywhere it's already visible, including over a constant or parameter. Every name means one thing.
- Constants are initialized in order. An initializer may use literals, operators, earlier constants and pure built-ins, but can't call functions, so initialization order never matters.
let _ = f()discards a result on purpose. Silently ignoring a non-Unit result is an error.
Control flow
if x < 0 then
print("negative")
else if x == 0 then
print("zero")
else
print("positive")
end if
while n > 1 do
n = n / 2
end while
for i in 0 ..< 10 do # 0..9
print(show(i))
end for
for i in 1 ..= 10 do # 1..10
print(show(i))
end for
for x in xs do # elements
print(show(x))
end for
break and continue work in loops. Comparisons don't chain (a < b < c is an error); write a < b and b < c. Boolean operators are the words and, or and not.
Functions
fn hypot(a: Float, b: Float) -> Float
return sqrt(a * a + b * b)
end fn hypot
Every parameter is annotated. Omit -> T for Unit. Before the body comes an optional header of clauses, in any order: requires, ensures, example and effects. A function with a return type must return on every path, and the checker verifies this. Functions are not values, so they can't be stored or passed around.
Contracts and examples
fn clamp(x: Int, lo: Int, hi: Int) -> Int
requires lo <= hi
ensures lo <= result and result <= hi
example clamp(15, 0, 10) == 10
example clamp(-3, 0, 10) == 0
if x < lo then
return lo
else if x > hi then
return hi
end if
return x
end fn clamp
requiresis checked on entry andensureson exit (resultis the return value). A failure traps and quotes the clause:ensures failed: result * result <= n.exampleclauses are tests attached to the definition.candor test file.cndruns them all, and a failing==example shows both sides.- All three kinds of clause must be pure: no effects.
Effects
A function that touches the outside world has to say so, and so does every function that calls it.
fn log_total(xs: [Int]) -> Int
effects io
var t = 0
for x in xs do
t = t + x
end for
print("total: " ++ show(t))
return t
end fn log_total
| Effect | Covers |
|---|---|
io | print, eprint, args, exit |
fs | read_file, write_file |
time | clock_ms |
A missing effect gets an exact fix: hint: add 'effects io' to the header of fn log_total (line 1). A function with no effects clause is pure, so you can reason about it without reading anything else.
Typed holes
When you don't know something, write a hole instead of guessing:
fn convert(amount: Float, currency: Str) -> Float
let rate: Float = ?exchange_rate
return amount * rate
end fn convert
$ candor check convert.cnd
hole ?exchange_rate : Float (convert.cnd:2:21, in fn convert)
in scope: amount: Float, currency: Str
Holes type-check, so the rest of the program is still verified around them. Reaching one at runtime traps (reached hole ?exchange_rate), and candor build refuses to produce a binary while any remain, unless you pass --allow-holes. A hole needs a known type from its context, such as an annotation, a parameter or a return type.
Records
type Point = record
x: Float
y: Float
end type Point
fn shifted(p: Point, dx: Float) -> Point
var q = p
q.x = q.x + dx
return q
end fn shifted
fn origin() -> Point
return Point { x: 0.0, y: 0.0 } # every field, explicitly
end fn origin
Records are plain values. A record can't contain itself directly; use an enum or an array for recursive data.
Enums and match
type List = enum
Nil
Cons(head: Int, tail: List)
end type List
fn sum(l: List) -> Int
match l
case Nil
return 0
case Cons(h, t)
return h + sum(t)
end match
end fn sum
matchmust be exhaustive: list every variant or end withcase _.- Variant names are global, so
case Nilis never ambiguous. - Enums whose variants carry no data support
==,!=andshow, which prints the variant name. Use them for kinds and states:if light == Red then. - Payload fields are read only through
match.
Distinct types
type UserId = distinct Int
type OrderId = distinct Int
fn next_user(id: UserId) -> UserId
return UserId(id.value + 1)
end fn next_user
- A distinct type has its base's representation but is a separate type. A
UserIdis never accepted where anIntor anOrderIdis expected. - Wrap with
UserId(5), unwrap withid.value(read-only). - You can use
==,!=andshow, and ordering when the base is numeric. A distinct Int can index arrays:names[id]. - Arithmetic is not allowed, since adding two ids is almost always a bug. When you do mean it, unwrap and rewrap:
UserId(id.value + 1). - The base must be Int, Float, Str or Bool.
error[type-mismatch] app.cnd:16:18
argument 'id' of lookup has type Int, expected UserId
hint: wrap it: UserId(...)
Value semantics
Assigning never creates a hidden alias:
var a = [1, 2, 3]
let b = a
push(a, 4)
# len(a) == 4, len(b) == 3
This costs less than it might seem. Function arguments are borrowed, so passing even a huge array is free because the callee can't change it. Copies happen only when a value escapes: when it's stored in a var, pushed, put in a literal, or returned, and only if it isn't fresh already. Returning a local var never copies.
inout and borrowing
Mutating a caller's variable is allowed, but it has to be visible at both ends:
type Parser = record
toks: [Str]
pos: Int
end type Parser
fn advance(inout p: Parser) -> Str
let t = p.toks[p.pos]
p.pos = p.pos + 1
return t
end fn advance
fn main()
effects io
var p = Parser { toks: ["a", "b"], pos: 0 }
print(advance(inout p)) # the call site says `inout`
end fn main
- An
inoutargument must be avarplace:x,xs[i]orr.field. - The same variable can't be passed
inouttwice in one call, or borrowed (if it's an array or record) while also passedinout. This rules out aliasing.
Built-ins
| Function | Notes |
|---|---|
print(s), eprint(s) | Print a line to stdout / stderr. Effect io. |
show(x) | Int, Float, Bool, Str, or a payload-free enum → Str. |
len(x) | Array length, or Str length in bytes. |
push(xs, v), pop(xs) | Append to / remove and return the last element of a var array. pop on an empty array traps. |
array(n, fill) | New array of n independent copies of fill. |
to_float(i), to_int(f) | Explicit conversions. to_int truncates, and traps outside Int range. |
sqrt(f), assert(b) | assert traps with the source text of the condition. |
byte_at(s, i), slice(s, a, b), from_byte(n) | Byte-level string access, bounds-checked. |
join(parts, sep) | Build strings efficiently from an array of pieces. |
parse_float(s) | Strict decimal parse. Traps on invalid input. |
args(), exit(code) | Program arguments and exit. Effect io. |
read_file(p), write_file(p, s) | Whole-file I/O. Effect fs. Traps on failure. |
file_exists(p) | true if p is a regular file. Effect fs. |
clock_ms() | Wall-clock milliseconds. Effect time. |
Traps
Runtime errors stop the program with a message naming the function, file and line, and exit with code 1:
trap in fact at fact.cnd:5: integer overflow
trap in main at app.cnd:5: index 3 out of bounds for length 3
trap in sqrt_int at math.cnd:3: ensures failed: result * result <= n
Integer overflow, bad indexes, division by zero, failed contracts and assertions, pop on an empty array, out-of-range to_int, and reached holes all trap. Nothing wraps or truncates silently.
The CLI
node src/cli.js check file.cnd [--json] # types, effects, holes
node src/cli.js run file.cnd [args...] # instant JS path
node src/cli.js run --native file.cnd [args] # build with clang, then run
node src/cli.js test file.cnd [--json] # run every example clause
node src/cli.js build file.cnd [-o out] [--emit-c] [--allow-holes]
Both backends implement the same semantics. Float printing, evaluation order (strictly left to right) and trap messages are identical, and the test suite enforces it byte for byte.
Reading diagnostics
error[effect-missing] demo.cnd:6:3
print(...) needs effect 'io', but fn log_total does not declare it
6 | print("total: " ++ show(t))
^
hint: add 'effects io' to the header of fn log_total (line 1)
Every error has a stable code (type-mismatch, effect-missing, non-exhaustive, inout-alias, const-order, …), an exact position, and usually a hint that is a concrete edit. With --json, tools and models get structured output with the same fields.