Candor.

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.

  1. Files, lines and blocks
  2. Modules
  3. Types
  4. let, var and constants
  5. Control flow
  6. Functions
  7. Contracts and examples
  8. Effects
  9. Typed holes
  10. Records
  11. Enums and match
  12. Distinct types
  13. Value semantics
  14. inout and borrowing
  15. Built-ins
  16. Traps
  17. The CLI
  18. 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
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

TypeMeaning
IntInteger in ±(253−1). Overflow traps instead of wrapping. / truncates toward zero.
FloatIEEE-754 double. show prints the shortest form that round-trips: 0.1 + 0.2 shows as 0.30000000000000004.
Booltrue / false. Nothing else is truthy.
StrImmutable UTF-8 bytes. len, byte_at and slice work on bytes.
UnitThe "no value" type: what a function without -> T returns.
[T]Growable array of T.
records, enumsDeclared 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

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

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
EffectCovers
ioprint, eprint, args, exit
fsread_file, write_file
timeclock_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

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
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

Built-ins

FunctionNotes
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.