Syntax basics
Recoil borrows Rebol's surface syntax. If you've never seen Rebol, here is everything you need — there are only a handful of lexical rules.
Words, values, and blocks
Code is made of words (names like print or
add), values (like 42 or
"hi"), and blocks — anything inside square
brackets [ ]. A block is just a grouped sequence of items; Recoil
uses blocks for function bodies, type specifications, and literal data alike.
; a comment starts with a semicolon and runs to end of line
print 42 ; a call: the word `print` applied to the value 42
Calls have no parentheses or commas
A function call is a word followed by its arguments, separated by spaces. Arguments are consumed left to right.
add 2 3 ; add(2, 3) in C terms
print add 2 3 ; print(add(2, 3))
Use parentheses only to group sub-expressions:
x: (10 + 5) * 2 ; 30
Set-words and get-words
A word ending in a colon is a set-word — it binds a value. A word starting with a colon is a get-word — it refers to something without moving it (used for borrowing and for passing functions).
count: 0 ; set-word: bind `count` to 0
greet :name ; get-word: borrow `name` for the call
Refinements and paths
A leading / marks a refinement — a named
variant or sub-call. A / between words or indices forms a
path.
file/open %data.txt ; `open` action in the `file` namespace
p/x ; field access
nums/0 ; index access (0-based)
nums/:idx ; index by a variable
Types end in !
By convention every type name ends with an exclamation mark:
i32!, logic!, string!,
struct!. The make word constructs typed values from a
type and an initializer.
point!: make struct! [x: i32! y: i32!]
Bindings & implicit typing
The idiomatic way to introduce a value is a set-word with a right-hand side. The type is inferred from that right-hand side — you rarely write it out.
count: 0 ; i32!
ratio: 3.14159 ; f64!
ch: #"A" ; char!
flag: true ; logic!
total: add 10 20 ; inferred from the call result → i32!
doubled: count * 2 ; inferred from the operands
When you need make
Use the explicit make Type! value form when inference has nothing
concrete to latch onto, or when you want a non-default type:
- a non-default width or type:
big: make i64! 1 - an explicitly owned
string!(a bare"..."literal is already a sharedstring!; it becomes ac-string!only where an FFI boundary needs one):msg: make string! "hello" - typed or empty containers and structs:
nums: make [#mutable vector! [i32! 3]] [] - a value-position expression with no concrete type:
x: make i32! if cond [1] - callables and ports:
cb: make fn-ptr! add,fp: make port! file/open %data.txt
Mutability
Bindings are immutable by default. There is no mut
keyword — mutability is part of the value's type spec, written inside
make [ ... ]:
buf: make [#mutable string!] "hello"
buf/0: #"H"
grow: make [#flexible [string! 16]] none
append grow "world"
#mutable— contents may be modified in place (fixed size).#flexible— may also grow/shrink (append,insert,remove).
Datatypes
Recoil has a concrete, statically-known type for every value.
Scalars
- Integers:
i8!i16!i32!i64!and unsignedu8!u16!u32!u64! - Floats:
f32!f64! logic!,char!,size!none!— a safe "absence of value";void!— "no value at all" (function return only, cannot be assigned)
Owned & resource types
string!— the user-facing owned stringurl!,file!,port!,error!,c-pointer!
Structured & container types
vector!(fixed-size, homogeneous),array!,dict!,slice!(zero-copy view)struct!,enum!,sum!(tagged union),fsm!,rule!,bitset!,fn-ptr!
ABI-facing types
Boundary types for FFI and the C ABI: c-string!,
c-struct!, c-array!, c-fn!. In ordinary
code prefer string!; use c-string! only at the C
boundary. See FFI & Systems.
Copy, move, and borrow
How a value behaves on assignment depends on its type:
| Category | Types | On assignment |
|---|---|---|
| Copy | integers, floats, logic!, char!, enum!, size! | duplicated; original stays valid |
| Shared | string!, block!, vector!, array! (and their #flexible forms) | reference count incremented; both bindings stay valid |
| Move | #unique series, dict!, map!, binary!, port!, error!, c-pointer!, and other pointer-like values | ownership transfers; original becomes inaccessible |
| Borrow | slice! and get-word references | a view; does not take ownership |
Native series are shared by default: assigning or passing one
just adds a reference. Add #unique to opt back into move-only
ownership (see Memory & sharing).
Sum types inherit move sensitivity from their variant fields.
See Ownership & borrowing below for the rules the borrow checker enforces.
Indexed paths
voice!: make struct! [phase: f64! active: logic!]
voices: make [#mutable vector! [voice! 2]] []
idx: 1
voices/:idx/phase: 0.5 ; field through a variable index
print voices/:idx/phase
Control flow & expressions
Recoil is expression-oriented: most control forms produce a value.
if and either
Both are expressions. The chosen branch's last value becomes the result. An
expression if usually appears in a typed context such as
make.
if x > 10 [print "big"]
if [x > 10] [print "big"] ; block condition also works
label: either x > 10 [
1
] [
2
]
maybe: make i32! if true [1] ; value-position if needs an explicit type
match over sums
Branch on a sum! value and bind its payload
(see Sum types & pattern matching):
status: match value [
some [payload] [1]
none [0]
]
all and any
Short-circuiting boolean expressions:
ok: make logic! all [1 = 1 2 = 2]
some: make logic! any [0 = 1 2 = 2]
Loops
while, repeat, and foreach are also
expressions: each yields its body's last value from the final iteration
(boxed as value!), or a boxed none! if the body never
ran.
i: make [#mutable i32!] 0
while [i < 5] [
print i
i: i + 1
]
repeat n 10 [print n] ; 0..9
foreach [key val] counts [ ; two-variable iteration over a dict
print val
]
Inside loops, break and continue behave as usual.
return expr exits the enclosing function.
go
go [body] queues a task body onto the runtime task queue. It is a
statement only and produces no value; tasks drain in FIFO order.
print "main"
go [
print "task"
]
Sum types & pattern matching
A sum! is a tagged union: a value is exactly one of its named
variants, and each variant may carry fields. match is how you take
one apart.
Declaring and constructing
Variants are listed in a block. A variant with a field block carries a
payload; a bare word is a payload-free variant. Construct a value with
Type!/variant, giving the fields in a block:
Shape!: make sum! [
circle [radius: i32!]
square [side: i32!]
]
c: make Shape! Shape!/circle [radius: 3]
s: make Shape! Shape!/square [side: 4]
A sum copies or moves according to its variant field types, so a variant
holding a string! or dict! behaves like that field
does.
Matching and binding
Each arm is variant [bindings] [body]. The bindings name the
payload fields in declaration order, and are visible only inside that arm. A
payload-free variant takes an empty binding block. match is an
expression, so it can be the last value of a function:
describe: func [sh [Shape!] return: [string!]] [
match sh [
circle [r] [rejoin ["circle-" r]]
square [s] [rejoin ["square-" s]]
]
]
print describe make Shape! Shape!/circle [radius: 3]
In value position every arm must produce the same type. In statement position the result is discarded, so arms may disagree, which is how the common drain-a-scanner loop is written:
Step!: make sum! [
ok [n: i32!]
need-more
done
]
step: Step!/ok [n: 7]
match step [
ok [n] [append out #"K"]
need-more [] [active: false]
done [] [active: false]
]
Exhaustiveness and default
A match must cover every variant, or end in a
default arm. default takes no bindings and must come
last. Leaving a variant out is a compile-time error, not a runtime surprise:
out: make i32! match value [
ok [payload] [payload] ; P1622: missing branch(es) for [error]
]
match blk/0 [
rv-i32 [v] [print v]
default [print "other"]
]
| Code | Raised when |
|---|---|
| P1614 | the subject is not a sum! value |
| P1616 / P1617 | a variant is matched twice / is not a variant of the subject |
| P1618 | an arm binds the wrong number of payload fields |
| P1620 | an arm binds the same name twice |
| P1621 | the match has no arms |
| P1622 | a variant is missing and there is no default |
| P1623 / P1624 / P1625 | more than one default / it is not last / it has bindings |
| P1626 | value-position arms produce different types |
Matching on plain values
match only takes a sum! subject. To branch on a
literal scalar, use switch; it is an expression and requires a
default branch (P1737):
n: make i32! 2
result: make i32! switch n [
1 [10]
2 [20]
default [30]
]
Functions
Functions are defined with func: a spec block (parameters,
optional doc strings, and a return: type) followed by a body
block. The last expression is the result unless an earlier
return exits first.
add: func [
"Add two signed 32-bit integers."
a [i32!] "Left operand."
b [i32!] "Right operand."
return: [i32!] "The sum."
] [
a + b
]
print add 7 8 ; 15
A function with no return: returns void! and cannot be
assigned.
Borrowed parameters
Mark a parameter with a get-word to borrow it instead of taking ownership. Pass the argument as a get-word too:
greet: func [:name [string!] return: [none!]] [
print name
]
msg: make string! "hello"
greet :msg
print msg ; still valid — msg was borrowed, not moved
Method chaining
A refinement call passes the value on its left as the first argument, so calls chain left to right:
result: 10 /add 5 /mul 2 ; mul(add(10, 5), 2) = 30
Functions used in a chain must not return void!.
Exporting
Mark a function #export to expose it from a generated library
(see Packages & Libraries):
init: func [#export return: [none!]] [
print "ready"
]
Closures & function pointers
Callable values have type fn-ptr! and are introduced with
make fn-ptr!. A closure captures variables from its enclosing
scope via an explicit #capture list in its spec.
A plain function pointer
add: func [x [i32!] y [i32!] return: [i32!]] [
return x + y
]
callback: make fn-ptr! add
print callback 3 4 ; 7
Capture by value
base: 100
bump: make fn-ptr! func [#capture [base] a [i32!] return: [i32!]] [
a + base
]
print bump 7 ; 107
Capture by reference
Capture a mutable binding by get-word to mutate it through the closure:
counter: make [#mutable i32!] 0
inc: make fn-ptr! func [#capture [:counter] return: [i32!]] [
counter: counter + 1
return counter
]
print inc ; 1
print inc ; 2
func: the spec (which holds both
#capture and the typed parameters) and the body. A third block
leaves the body empty.
Generics
A function becomes generic when generic [T] precedes its
func. The names in the block are type parameters, usable anywhere a
type is: parameters, the return type, and size-of T.
min-value: generic [T] func [
"Return the smaller of two comparable values."
left [T] "The first candidate value."
right [T] "The second candidate value."
return: [T] "The smaller input value."
] [
if left < right [return left]
return right
]
Calling a generic
Pass the concrete types in a block straight after the function name, one per type parameter. A call that omits them is rejected with requires explicit type arguments, and an argument that does not match the substituted parameter type is a type error.
print min-value [i64!] 9 4
print clamp-value [i32!] 20 0 9
quicksort [i64!] all-i64 scratch-i64-view
The standard library uses this form throughout: std/math/core
(min-value, max-value, clamp-value) and
std/sort (quicksort, mergesort).
How instantiation works
Generics are resolved at compile time, before borrow checking. The
monomorphization pass finds each call with concrete type arguments, clones the
generic function with the parameters substituted, and rewrites the call to the
clone. Each distinct argument tuple produces one ordinary function with a
mangled name, so clamp-value [i64!] and
clamp-value [i32!] become two separate C functions
(clamp_value__g__i64_t, clamp_value__g__i32_t). There
is no boxing and no runtime dispatch. Because the body is checked per
instantiation, size-of T emits a concrete
sizeof(...):
type-size: generic [T] func [
"Return the concrete type size."
return: [size!] "Concrete type size in bytes."
] [
return size-of T
]
primitive-bytes: make size! type-size [i32!]
Limits
- Type arguments are always explicit; they are not inferred from the value arguments.
- The number of type arguments must equal the number of parameters in
generic [...]. - A qualified call such as
dep/a/fcannot instantiate a generic (P1749); import the name and call it unqualified. - A generic is a template: it produces no code until some call instantiates it.
Ownership & borrowing
Recoil's defining feature is a static borrow checker that
enforces memory safety at compile time, with no garbage collector. Native
series (string!, block!, vector!,
array!) are reference-counted and shared by
default; every other owned type, and any series marked
#unique, is move-only. The checker tracks each
binding as owned (holds valid data), moved
(ownership transferred away — using it is an error), or
borrowed (referenced without transfer).
Shared by default
text: "hello"
alias: text ; adds a reference
print alias
print text ; ok — both bindings share the value
Move semantics
Move-only values are consumed by assignment or by passing them to an owning
parameter. Add #unique to a native series to get this behaviour
on purpose:
s1: make [#unique string!] "Hello"
s2: s1 ; ownership moves to s2
print s1 ; compile error: 's1' used after move
's1' used after move
The same holds for ports, errors, dicts, maps, binary! and
pointer-like values, which are always move-sensitive.
Recovering ownership
Assigning a fresh value makes a moved binding owned again:
s1: make [#unique string!] "Hello"
s2: s1 ; s1 moved away
s1: make [#unique string!] "Fresh" ; s1 owned again
print s1 ; ok
Borrowing avoids the move
A get-word parameter borrows: the callee gets a view, the caller keeps ownership, and the callee gains no right to mutate.
greet: func [:name [string!]] [print name]
msg: make [#unique string!] "hi"
greet :msg ; borrowed
print msg ; still owned
print is itself a borrowing operation, so printing a binding
never consumes it.
Branches
If a binding is moved in any branch, it is considered moved afterward, because the checker cannot know which branch ran:
either condition [
consume s1 ; moves s1
] [
print s1 ; ok in this branch
]
print s1 ; error: 's1' used after move
Mutability is enforced too
s: make string! "hi"
s/0: #"H" ; error: 's' is not mutable (B3003)
s: make [#mutable string!] "hi"
s/0: #"H" ; ok
Continue with Memory & sharing for
#unique, stack vectors, series capabilities and views.
Error handling
Recoil reports problems at three layers: parse/type errors
and borrow-checker errors at compile time, and
runtime errors through a structured error!
value.
The error! value
e: make error! "something went wrong"
Automatic runtime checks
Division by zero, integer overflow, and out-of-bounds indexing set a
thread-local error state instead of crashing. Check the global state with
the zero-arity errored?, or test a specific value with
error?:
result: 10 / 0
if errored? [print "a math error occurred"]
if error? e [print "this specific value is an error"]
Cleanup with defer
defer runs a block when the function exits (LIFO).
defer/error runs only when an error is set.
fp: make port! file/open %data.txt
defer [file/close fp] ; always runs on exit
defer/error [print "cleaning up after failure"]
Live and sticky error slots
Errors travel through two thread-local slots rather than result types.
- The live slot holds the most recent error. It is
cleared on function entry, by
errored?, and on entry to a runtime function. An error you ignore therefore stays live until one of those, instead of being erased by the next successful read. - The sticky slot is set only by arithmetic faults (overflow, divide by zero) and is not cleared by ordinary operations. It exists so that an unhandled math error survives until the program ends.
If two fallible fragments in one expression both fail, such as
a / 0 + b / 0, one of the two errors is left in the slot but
which one is unspecified. Split the expression across statements when you
need to know.
errored? consumes the error
errored? takes no operand and clears both slots:
checking an error counts as handling it. A second check returns false.
error? takes exactly one operand and only tests that value.
a: make i32! 2147483647
b: make i32! a + 1
if errored? [print "caught"]
either errored? [print "again"] [print "consumed"]
This prints caught, then consumed.
Returning an error!
A function that can fail may return an error! where its
return type allows; the caller tests the result with error?:
try-divide: func [a [i32!] b [i32!] return: [i32!]] [
if b = 0 [
return make error! "zero"
]
return a / b
]
result: make i32! try-divide 10 0
if error? result [print 1]
defer/error as a failure guard
defer/error fires on return only when the live slot holds an
error at that moment, which makes it the place for rollback. Plain
defer blocks run last-registered first.
with-error: func [return: [i32!]] [
defer/error [print 1]
return make error! "oops"
]
without-error: func [return: [i32!]] [
defer/error [print 9]
return 0
]
The unhandled math-error trap
An overflow that returns 0 would be indistinguishable from a
real result, so the generated main ends by reading the sticky slot.
An unhandled overflow or divide by zero prints
error: ... (code N) to stderr and exits with status 1; one cleared
with errored? exits 0. Only math errors trap: port, parse, user and
FFI errors are ordinary conditions programs handle themselves. Exported
library entry points are not trapped either, because the embedding caller owns
error inspection.
Error categories
| Range | Category | Examples |
|---|---|---|
| 300–302 | Math | division by zero, integer overflow, underflow |
| 500 | FFI | null pointer |
| 700–701 | Series | index out of bounds, past end |
| 800–841 | Port | TCP, TLS, HTTP and DNS failures |
| 850–853 | Parse / FST | no match, unexpected end |
| 860 | Channel | closed channel |
| 900+ | User | custom application errors |
The full taxonomy and the diagnostics format live in the Reference.