Declarations
Bindings, functions, structs, enums and type declarations — what each form states, and what it leaves to be inferred.
A declaration is a statement, so anything that can be declared at the top of a file can also be declared inside a function body. A helper function used by one function belongs inside it, and needs no separate rule to allow it.
Bindings
Three forms, and the differences between them are about when the value is fixed rather than about where it lives.
| form | mutable | value | type |
|---|---|---|---|
var name = v | yes | optional — a var may be declared and assigned later | optional |
val name = v | no, written once | required | optional |
const Name: T = v | no, and known while compiling | required | required |
The type is mandatory on a const and optional on a val, and that arrangement is deliberate in
both directions. A const states an interface — its value is substituted where it is used, so what
that value is matters less than what it is a value of. A val has its type readable off the
value it was given, and a val with no value is not a declaration of anything.
The value may be an indented block
A binding’s = takes one expression, or an indented block whose trailing expression is the
value — the same arrangement a function body has, and the same one an if branch has. What the block
binds is the block’s own, and goes out of scope with it:
val limits =
val raw = 7
val capped = raw * 2
capped
print(limits)
14
Reach for it when the value takes more than one step to work out. The alternative is a name in the enclosing scope that exists only to be read once on the next line, which says the value is available to everything below when it is not.
It composes with the branching forms rather than competing with them — an if too long for its line
goes on the line under the =, which is the shape the form was built for:
pick(c: bool) -> int
val n =
if c then
val a = 3
a + 1
else
0
n * 10
print(pick(true), pick(false))
40 0
A block of one expression is that expression. Moving a value to the next line to fit the margin
changes nothing else about the declaration: a module val written that way is still laid into the
object file, rather than becoming something the program computes before it starts.
A block that ends in something other than an expression yields unit, exactly as an if with no
else does, and the complaint arrives where the value is used rather than where it was bound.
A const does not take one. A constant is folded into every use of it rather than run, so there
is nowhere for the statements to happen. Its value may still sit on the next line — that is one
expression rather than a block, by the rule above — and what is refused is a block that binds
something:
const Limit: int =
val base = 3
base * 2
print(Limit)
'Limit' is a 'const', so its value is folded into every use of it
A module member states its type
Where a binding is a member of a module rather than a local, the annotation stops being optional
even on a val. That is a rule about where the binding was written, so the grammar accepts either
form and the analyzer applies the rule.
It is worth reading as one rule rather than two, because it is one: anything visible outside its
file states its types, and a module member always could be, while a local states nothing to anyone
and so infers exactly as a var does. Most of it the syntax already enforced — a parameter type and
a field type are mandatory, and an absent return type means unit rather than being inferred — so
a module-level binding is simply the last declaration the rule had left to reach.
The payoff is that interface extraction is parse-only. A file’s exported surface can be read off its syntax tree without resolving a name, checking a body, or having compiled anything the file imports, which is what lets the collect pass depend on nothing but parsing — and that is what a fast, parallel, eventually incremental build rests on. Scala infers types for public members and pays for it with a far heavier extraction step; this is a deliberate divergence, and a cheap one here because sysl’s signatures were already explicit for other reasons.
const Limit: int = 3
static val Scale: int = 2
show()
var count = 0
val doubled = Limit * Scale
val name = "sysl"
count += 1
print(count, doubled, name, Limit)
show()
1 6 sysl 3
The two vals in that program are one keyword doing one thing: the module member says : int
because it is visible outside its file, and the one inside show is visible to nobody and does not
have to. The static is what asks for the member — this is the file the program starts in, so a plain
val there would be a local of its body, and a local infers exactly as a var does.
A module-level val may hold a counted value, and never releases it. Storage that exists for the
whole run is never let go of, so the one release with nowhere to go is the one at exit — and not
taking it is what a static is. A &T, a weak T, a slice and a string the program builds
are all admissible:
static val name: string = str(2026)
print(name)
2026
That was refused until the reason was read again, and what it cost was every shape that needs a
value to outlive every frame. What the initializer is still decides where the storage gets filled —
a literal‘s bytes are a constant in the object file and its owner word is null, so nothing is
allocated and the storage is complete before the program starts, while a built value is stored by a
prologue. That difference is why a module with no alloc may still hold a table of literals:
val greeting: string = "hello"
val names: [3]string = ["alpha", "beta", "gamma"]
var i = 1
print(greeting, names[i])
hello beta
This is what a module with no allocator uses for its messages. A const could not serve it: a
constant is folded into its uses and has no address, so it cannot be indexed at a position computed
while running. Anything the program had to build is a local instead, which is ordinary — that is
what the first program above does with its val name.
A raw pointer may be held, because it counts nothing and so there is no release to write. What a
val promises is that its own storage is written once and never again, and holding an address
keeps that promise exactly as holding a number does. This is the shape that has no substitute — a
device register block named at file scope, reached by every function in the driver rather than
re-materialised in each:
const UART: usize = 0x1000_0000
val regs: *Uart = ptr_cast(UART)
An address that is a constant is laid into the object file rather than stored by a prologue, so a
val at pointer type needs nothing ordered and is readable before the first initializer runs — which
is what a freestanding program starting at a reset vector requires.
Writing to a val twice is refused:
show()
val name = "sysl"
name = "other"
print(name)
show()
a 'val' is written once, so assignment has nothing to write through
Read-only means read-only at every depth. k = …, k[0] = …, k[0] += 1 and k[0]++ are all
refused on a module-level val, and so is &k[0] — a *T is a licence to write, and handing one
out would move the mistake one step away from where it could still be reported.
Slicing is allowed, and yields a []const T. That is what lets a table be read and passed
rather than only read: the read-only property travels with the view, through a name, through a call,
and through a second subscript, so every write above is refused through it too. &v[0] on such a
view is a *T, the tier the memory model excludes on purpose, and is how the view reaches C.
The one module-level storage none of this reaches is an extern variable. Every rule above is a
promise this program makes about storage it laid down; an extern variable is storage the linker
supplies — stdout, environ, optind — so there is no such promise to keep. It is a place, it may
be written, and it holds whatever the other side put there.
Several at once
var and val both take a comma list, binding several names to several values. Each part’s type is
inferred from its own value.
show()
val a, b = 1, 2
var lo, hi = 0, 10
print(a, b, lo, hi)
show()
1 2 0 10
Two or more names and an initializer are both required: one name is the ordinary form, and a multiple binding with nothing to take apart names nothing. The right side is produced before any name is bound, so a value there still means whatever the enclosing scope calls it — the binding does not shadow itself half way through its own right-hand side.
It is a local form. The parts carry no type annotation and there is nowhere to write one, so a
multiple val at the top of a file collides with the rule above and is refused rather than becoming
a quiet local of the entry point. A module member that wants the form declares its names separately.
The same spelling also takes a result list and a tuple apart, which is why a function with several results needs no special form at the call.
By pattern, when the shape matters
A comma list says how many things to bind. A pattern says the shape, and so reaches inside a tuple that holds another one — which is the whole of the difference between the two forms.
show()
val (a, b) = (1, 2)
val ((x, y), z) = ((3, 4), 5)
val (first, _) = (6, 7)
var (lo, hi) = (0, 10)
hi = hi + 1
print(a, b, x, y, z, first, lo, hi)
show()
1 2 3 4 5 6 0 11
A _ binds nothing and skips its part. A var pattern makes every name it binds assignable, and a
val pattern makes each of them write-once, exactly as the single-name forms do.
A struct pattern stands here on the same terms, naming fields rather than positions. It may name them in any order, may leave fields out, and may rename one to a sub-pattern:
struct Point
x: int
y: int
struct Line
a: Point
b: Point
show()
val Point{x, y} = Point(3, 4)
val Line{a: Point{x: ax}, b} = Line(Point(1, 2), Point(5, 6))
print(x, y, ax, b.x, b.y)
show()
3 4 1 5 6
A field the pattern does not name simply binds nothing — unlike a match arm, a binding has no
exhaustiveness to discharge.
The positional spelling Point(a, b) stands here too, and takes the fields in declaration order.
It differs in the one way it differs in a match: it names every field, so a struct that grows one
turns each positional binding into a checked to-do rather than one that goes on binding the same
names. See Patterns.
Only a pattern that cannot fail may stand at a binding — a tuple pattern, a struct pattern, a name, a wildcard, and those nested inside one another. A struct qualifies because it has exactly one shape, which is the same property that makes a tuple pattern irrefutable.
A literal, a range, or a variant is a test, and a binding has no other arm to take when the
test does not match, so each is refused with that as the reason. Those belong in a match (see
Patterns).
Like the comma form, this is a local form: the parts have nowhere to carry a type, so one at the top of a file is refused rather than becoming a quiet local of the entry point.
Functions
A name, a parameter list, an optional -> result, and a body. There is no keyword: the shape is what
identifies it. An absent result type means unit.
The body is either = expr — whose value is the result — or an indented block, whose trailing
expression is the result.
double(n: int) -> int = n * 2
sum(a: int, b: int) -> int
var t = a + b
t
greet(name: string)
print("hi", name)
print(double(21), sum(40, 2))
greet("you")
42 42
hi you
= may also open an indented block, so a body does not have to change shape when it outgrows a
line.
Tail calls
A function whose last act is a call to itself does not open a second frame. The call becomes a branch back to the function’s own entry, so the recursion is bounded by the arithmetic rather than by the stack:
count(n: int, acc: int) -> int =
if n == 0 then acc else count(n - 1, acc + 1)
print(count(1000000, 0))
1000000
A million frames is not a stack any machine has. Nothing is written to ask for this — it applies wherever it applies.
In tail position is the last thing the function does: the body’s trailing expression, the operand
of a return, and the arms of the if and match those reach through. Nothing may wait on the
result — n + count(n - 1) is an ordinary call, because the addition happens after it comes back.
A tail call is a call, so the jump lands where a call would: every require is checked again on
the arguments the jump wrote, and every old(e) is snapshotted again. A recursion that violates
its own precondition at depth four stops at depth four.
Two things end a tail position instead of being optimized around:
- a
deferin scope, which runs on the way out of a scope — after the callee returns for an ordinary call, and before it is entered for a jump; - an
ensureson the function, which is checked when a call returns, and a tail call never returns.
Either one leaves the function compiled exactly as written.
It is self-recursion only. Mutual recursion and calls through a Fn or a *extern are ordinary
calls, however they are written.
@tailrec
The jump is silent, which is what you want until an edit takes it away. @tailrec asserts it is
there and is refused when it is not:
@tailrec
sum(n: int) -> int =
if n == 0 then 0 else n + sum(n - 1)
print(sum(5))
calls itself nowhere the jump can replace
It changes nothing about what is emitted — write it on the functions where losing the jump silently would be a bug, and leave it off the rest.
Several results
A signature may declare more than one result, and the trailing expression or return supplies them
as a comma list.
minmax(a: int, b: int) -> int, int
if a < b
a, b
else
b, a
show()
var lo, hi = minmax(9, 4)
print(lo, hi)
show()
4 9
This is not a tuple. A tuple is a value with a type of its own; a result list is several values handed back at once, and the multiple binding above is what receives them.
A result list is a whole line by construction, so it cannot be the body of an inline branch:
if a < b then a, b else b, a does not parse, because the comma there would have to belong to the
branch rather than to whatever expression the branch is part of. The block form above is how a
conditional supplies several results.
Default parameters and named arguments
A parameter may say what a call that leaves it out gets instead. The default is a full expression, so a call or a conditional may stand there — it is evaluated at the call site that omitted it, and it may not name anything local to the declaration.
An argument may be written name = value, which stands at the parameter it names rather than at the
one its position would have given it.
box(w: int, h: int = 1, fill: string = "*") -> string
var s = ""
for i in 0..<w * h
s += fill
s
print(box(3), box(2, 2), box(2, fill = "#"))
*** **** ##
name = value is also a legal expression — assignment yields the value stored — so the two readings
collide, and the named argument is the one taken. It applies only where the name is a bare
identifier: p.x = 1 and b[i] = v are stores as they always were, since neither is a name a
parameter list could have written. A store to a plain variable is still reachable in an argument by
parenthesizing it.
A closure’s parameter declares no default. A call reaches a closure through the Fn traits,
which carry the types and not the names, so there would be nothing at the call to fill one from.
Overloading
A name may be declared more than once, and every use of it still means exactly one declaration. Which one is decided by the arguments a use passes: how many, and what type each is.
show(x: int) -> string = s"int $x"
show(x: string) -> string = s"str $x"
show(x: int, y: int) -> string = s"pair $x $y"
print(show(1))
print(show("a"))
print(show(1, 2))
int 1
str a
pair 1 2
The result is never part of it. A pair differing only in what they return is refused where the second is written. sysl reads an expected type inwards, from the context to the expression, so a call whose meaning depended on its own result would need its context typed before it could be resolved and would need resolving before its context could be typed.
h(x: int) -> string = "s"
h(x: int) -> int = 1
function 'h' is already declared — which declaration a call means is decided by its arguments and never by what it returns, so two that differ only in the result have no call that tells them apart
That is the plain duplicate message with its sentence finished, and the wording is deliberate: the two parameter lists are the same, so this is one declaration written twice. Somebody who wrote that by accident is told so plainly; the clause is added only where the results differ, because that is the pair a reader wrote on purpose and expected to work.
The rule behind it is wider than the identical case. Each declaration takes a range of argument counts — from its parameters that have no default up to all of them — and two collide when their ranges overlap at some count and their first that-many parameters agree in type. So a difference hidden behind a default is refused too, and there the point is sharper: the default is unreachable, because no call could ever supply one argument to the longer declaration.
g(x: int) -> string = "one"
g(x: int, y: int = 0) -> string = "two"
'g' is already declared with parameters this one could not be told from — a call passing 1 argument would fit both, and which declaration a call means is decided by its arguments and never by what it returns. Two declarations of one name have to differ in a way a call site can show
Reporting that at the declaration rather than at the call is the point. The mistake is in the pair; reporting it where the name is used would report one mistake once per call site, in files whose authors did not write it.
A use that fits none of them, or several, is refused where the use is, and the message carries the roster — the reader’s question at that point is which declarations exist:
k(x: int) -> string = "a"
k(x: string) -> string = "b"
print(k(1.5))
no 'k' takes these arguments — the declarations of that name are:
k(x: int)
k(x: string)
Three tie-breaks decide a use that fits more than one. A candidate that needed no default fitted the call as written and beats one that did; and a candidate whose parameters are exactly the arguments’ own types beats one reached by a conversion — which is what lets a literal’s natural type choose between two widths:
width(x: int) -> string = "int"
width(x: i64) -> string = "i64"
print(width(1))
print(width(1i64))
int
i64
What is deliberately absent is any ranking between conversions. Two candidates each reached by a different one are ambiguous, and saying so beats a ladder of precedences nobody could predict from the source.
Exactness is asked of the types a candidate was fitted at, which for a generic one is what the call
solved it to. g[T] below takes the []int at T = []int, as it was written; the other takes it
only by giving up the ability to write. So the generic declaration is the exact one:
g(s: []const int) -> string = "const"
g[T](x: T) -> string = "generic"
var a = [1, 2, 3]
val v: []int = a[..]
print(g(v))
generic
The third tie-break is that a candidate that named its parameters beats one that was solved for
them, where both are exact. f(x: int) and f[T](x: T) both fit f(0) at int, and the ordinary
declaration is the one that said what it takes:
f(x: int) -> string = "plain"
f[T](x: T) -> string = "generic"
print(f(0))
plain
That ranks a declaration against a declaration, which is a different question from ranking the routes the arguments took — the thing the paragraph above refuses.
An address chooses by the type the context wants, which is the mechanism a generic function’s address already uses. With no expected type there is nothing to read, and the address is refused rather than guessed at.
add(a: int) -> int = a + 1
add(a: int, b: int) -> int = a + b
val one: *extern(int) -> int = &add
val two: *extern(int, int) -> int = &add
print(one(41), two(6, 7))
42 13
Overloading an extern
Two externs of one name are two functions exactly when they name two symbols. A C library’s
naming is not sysl’s, and a family C spells _solid/_shaded/_blended is one operation with an
option — which a binding may now say, without inventing a sysl name per C symbol.
extern "strlen" size(s: *u8) -> usize
extern "strnlen" size(s: *u8, cap: usize) -> usize
Two naming the same symbol are refused. That is one C function claimed at two signatures, and the symbol is what gets emitted — both calls would reach the same code with different arguments, and nothing downstream could tell which had been meant.
extern "strlen" size(s: *u8) -> usize
extern "strlen" size(s: *u8, cap: usize) -> usize
'size' is already declared as an 'extern' for the symbol 'strlen' — two declarations of one name are two functions, and one C function cannot be two. Overloads of an 'extern' are told apart by the symbol each names, so give this one a symbol of its own or take its address and 'ptr_cast' it where the other signature is wanted
An extern and a sysl function do not overload each other, in either order, for the same reason:
what tells overloads of an extern apart is the symbol, and a sysl function declares none.
Type parameters
A bracketed list directly after the name declares type parameters, with optional bounds. See generics and traits.
Structs
A named product type. Fields are declared one per line; methods, properties and an invariant may
follow among them, in any order.
struct Rect
w: int
h: int
invariant w > 0 && h > 0
area(self) -> int = self.w * self.h
perimeter -> int = 2 * (self.w + self.h)
scale(*self, k: int)
self.w *= k
self.h *= k
end Rect
var r = Rect(3, 4)
print(r.area(), r.perimeter)
r.scale(2)
print(r.area(), r.w, r.h)
12 14
48 6 8
Four things are on display there.
A constructor is the struct’s name applied to its fields, in declaration order. There is no separate constructor declaration to write or to keep in step.
A field declares no default. What an unwritten field gets is decided by the constructor that
builds the value, not by the field — and the compiler says so rather than leaving a = v after a
field to fail as whatever the grammar happened to want there.
A property is a method with the parameter list left off — perimeter -> int, called as
r.perimeter with no parentheses. It takes the same body forms a method does.
The receiver says how the method reaches its value, and is written as the first thing in the parameter list:
| receiver | meaning |
|---|---|
self | by value — the method gets a copy |
*self | by pointer — the method may write through it |
&self | by reference, counted |
&sync self | by reference, and safe to share across threads |
| (none) | an associated function — no receiver, called on the type |
struct Point
x: int
y: int
origin() -> Point = Point(0, 0)
var p = Point.origin()
print(p.x, p.y)
0 0
invariant
A condition every value of the struct must satisfy, re-checked whenever the struct is built or one of its fields is written. Bare field names are in scope inside it. A multiple assignment re-checks it once, after every write has landed, which is what lets two fields that relate to each other be changed together.
invariant is a contextual word — an ordinary identifier everywhere else. See
errors and contracts for what happens when one is broken.
A struct with no fields
A struct may declare no fields at all. Its emptiness has to be written — the end marker, optional
everywhere else, is what says so — because a struct whose body the author forgot to indent looks
exactly like one that has no body, and that is much the likelier mistake.
struct Stdout
end Stdout
impl Fallible for Stdout
impl Writer for Stdout
write(*self, bytes: []const u8) = putbytes(bytes)
end Stdout
show[T: Display](x: T)
var out = Stdout()
x.display(&out, FormatSpec(0, -1, false))
printc('\n')
end show
show(42)
show("through a sink of one's own")
print(sizeof(Stdout))
42
through a sink of one's own
0
What wants one is a sink: a value standing for a destination fixed at compile time — the console, a serial port — which has nothing to keep and so has no field to keep it in. Being a value rather than a global is what lets it be passed to a function, held in a struct, and chosen by a caller.
Such a type occupies no bytes, so embedding one costs the struct holding it nothing. The cost of that
is that two of them have nothing to tell their storage apart, and &a == &b on two such locals may
well be true. There is no state behind either address for the answer to be about.
opaque
opaque struct Name withholds the layout from every module but the one declaring it. Outside, the
type is incomplete: only *Name may be said, so a value cannot be built, copied, or have a field
read. This is a different axis from visibility — private decides who may say the name, opaque
decides who may know the shape. See modules.
An opaque struct with no body at all is C’s incomplete type, struct sqlite3; — nothing in sysl lays
one out, and the declaration exists so that *Session is a type a *u8 cannot be mistaken for.
Enums
Two shapes under one keyword. A simple enum is a set of named discriminants over an underlying integer type; a data enum gives its variants payloads, which makes it a sum type. Both may carry members.
enum Status: u8
Ok = 0
Warn = 10
Fail = 20
severe(self) -> bool = u8(self) >= 10
print(u8(Warn), Ok.severe(), Fail.severe())
10 false true
The : u8 pins the storage; without it the compiler picks. A variant is a bare name, a name with an
explicit integer value, or a name with a payload:
enum Shape
Circle(r: real)
Rect(w: real, h: real)
area(self) -> real = self match
Circle(r) -> 3.14159 * r * r
Rect(w, h) -> w * h
print(Circle(1.0).area(), Rect(2.0, 3.0).area())
3.14159 6
A member is told from a variant by what follows it: a member needs a body after its header, so
Circle(r: real) — a header with nothing after it — is a variant.
Option[T] and Result[T, E] are ordinary data enums declared in the standard library, with no
compiler privileges.
Type declarations
type Name = Existing introduces a second spelling, interchangeable with the first. It creates no
new type and no checking — an alias is for shortening a name that has grown long.
Adding new makes it a genuinely distinct type, and within and where add checked bounds:
type Meters = new f64
type Slot = new u8 within 0..<8
var d = Meters(1.5)
var s = Slot(3)
print(f64(d) * 2.0, u8(s))
3 3
new, within and where are contextual words, so a function or field may still be named where.
See errors and contracts for what a bound costs and when it is checked.
Traits, impls, and externs
trait Name declares a set of requirements; impl Trait for Type supplies them. Both are covered on
traits.
extern declares a function or a variable the other side of the link owns, and is covered on
the foreign interface.
Visibility
A declaration is public unless it says otherwise — the unmarked case is the one that writes nothing, because it is the common one.
| written | reach |
|---|---|
| (nothing) | public — anything that can see the module can see it |
private | this file only |
private[mod] | the named enclosing module and everything under it |
A struct’s and an enum’s members and fields each take their own modifier, so a type may be public
while part of its shape is not. A trait’s members and an impl block’s take none: a trait’s member is
as visible as the trait, and an implementation supplies what the trait asked for.
The details — what a module is, how the reach is computed, and how visibility interacts with opaque
— are on modules.
end markers
Every block-shaped declaration may be closed with end Name, naming what it closes. It is optional
everywhere and checked when written, so it cannot drift from the thing it claims to close.
The one place it is required is a struct with no fields, where it is the only thing distinguishing a body that is deliberately empty from one that was meant to be there.
end is a soft word: it is an ordinary identifier everywhere except immediately before a name or
a construct keyword, so end stays usable as a variable.
Next: patterns and matching.