sysl

Types

Every type the language has — the open integer families, the closed float set, and the aggregates built on them.

sysl’s types fall into four groups: scalars (numbers, bool, char), the built-in string, the aggregates you build (arrays, slices, structs, enums), and the modes that decide where a value lives (T, &T, *T). This page covers the first three. The modes have a page of their own, because they are about storage rather than shape.

There is no implicit conversion anywhere in the language. Every width change, signedness change, and float/integer crossing is written as a cast, and a cast that could lose information is written and seen.

Integers are an open family

iN and uN are not a fixed set of four sizes. They are an open family parameterized by an arbitrary bit width: i5, u3, u12, i128 are all types you may write, and none of them needed the compiler to have heard of them. LLVM supports integers of any width natively, so this is a capability of the target rather than something sysl emulates.

var small: u3 = 5
var odd: i5 = -7
var wide: u12 = 4000

print(small, odd, wide)
5 -7 4000

That openness is the reason the iN spelling exists at all. If the integers were only four sizes, carrying both iN names and C-style names would be two spellings for every type and no benefit. Instead there are two layers, each earning its place: iN/uN is the general mechanism, and a short list of aliases covers the common widths.

aliasisaliasis
byteu8shorti16
ushortu16inti32
uintu32longi64
ulongu64realf64

C’s “how wide is long?” problem — the usual reason to distrust names like these — does not apply, because each width is pinned by definition. long is exactly i64, on every target, always.

But that is an anti-ambiguity guarantee, not an ABI promise, and the difference matters at a foreign boundary. On a 64-bit host every alias happens to match its C namesake, which is what makes them safe in extern code there. On a 32-bit target C’s long is 32 bits while sysl’s is still exactly 64 — so precise FFI should use the explicit-width names, which match C’s int32_t and int64_t on every target. Note also that i8 has no alias: there is no settled C-style name for a signed byte worth adopting.

Arithmetic wraps

Integer arithmetic wraps at the declared width, and this is defined behaviour rather than a checked error. i5 wraps mod 2⁵ exactly as i32 wraps mod 2³².

var small: byte = 250

print(small + 10)
4

Overflow is therefore not a trap source. See errors and traps for what is.

Storage is not N / 8

An integer’s alignment rounds up to the smallest width the target names, and its stride rounds up to that alignment. A u12 occupies two bytes aligned to two; a u96 occupies sixteen aligned to sixteen. This is LLVM’s rule, and it matters anywhere a width is stated rather than derived.

Where the width stops

Up to 64 bits everything is a machine instruction. Past that the arithmetic is still native — the back end expands a wide multiply or divide inline, with no runtime routine behind it — and decimal rendering becomes the language’s own job, since C’s printf has no length modifier that wide. So a value past 64 bits renders through a digit loop and is refused a %d.

The ceiling is LLVM’s own 2²³ − 1, and a wider width is a diagnostic naming it. That is a statement about the toolchain, not about the design.

var wide: u256 = 1
var i = 0

while i < 200 do
    wide = wide * 2u256
    i = i + 1

print(wide)
1606938044258990275541962092341162602522202993782792835301376

Two costs are worth knowing before reaching for an extreme width, because neither is guarded against: the digit buffer is stack space proportional to the width, so a width near the ceiling overflows the frame, and sizing it evaluates 2^N at compile time. Nothing reaches either without asking for it by name.

The narrowest widths

N ≥ 1 has no exception at its low end. u1 is a single binary digit. i1 holds {-1, 0} — one bit of two’s complement, where the only bit is the sign bit. It is degenerate but entirely consistent, and nothing special-cases it: abs at i1 answers -1 because -1 is that width’s most negative value, exactly as abs at any width answers its own minimum, and signum never returns +1 because no value of the type is positive. If you want a bit, write u1 or bool.

Floating point is a closed set

fN is not open — only the IEEE widths exist, because there is no meaningful f37. f32 and f64 are the two the back end supports today.

Only the default width gets an alias, and it is deliberately named real rather than float, because float means 32-bit to every C, C++, Rust and Java programmer and real promises nothing it does not keep.

var a: f32 = 1.5
var b: real = 2.25

print(a + 1.0, b / 2.0)
2.5 1.125

A float renders in the shortest form that round-trips, so a value with no fractional part prints without one.

usize and isize

Pointer-width integers, and they are distinct types from every uN — not aliases for u64. That is required by the target scope: aliasing usize to u64 would be correct only if every target were 64-bit, and a 32-bit embedded target is squarely in scope.

A length, an index, and sizeof are all usize.

var xs = [10, 20, 30]
var n: usize = xs.len

print(n, xs[1])
3 20

bool

true and false, and nothing coerces to it. if x = 0 is a type error rather than a subtle bug, because if requires a bool and an assignment yields what was assigned. There is no integer-as-condition rule to memorize.

char

One Unicode scalar value — a codepoint up to 0x10FFFF, excluding the surrogate range. It is its own type, aliased to neither u8 nor u32.

Keeping it distinct is what lets “a character” and “a byte” stay different ideas, which a language with a UTF-8 string has to do. Conversion to u32 is total and written u32(c); conversion from u32 is partial, and has both a checked form that traps and a fallible form that returns an Option.

unit and never

unit is the type with exactly one value — what a function that returns nothing returns. It is a real type, so it composes: a Fn() -> unit needs no special case in the callable machinery.

never is the type with no values, and it is what an expression that does not finish has. A call to exit has type never, as does a return or a break considered as an expression. Because there are no values of it, never is a subtype of everything — which is what lets a match arm that aborts sit beside arms that produce an int without the arms disagreeing.

string

A validated UTF-8 string, three words wide. It is immutable, indexing it by byte position gives bytes, iterating it gives chars, and + concatenates but will not accept a non-string operand. Strings is the full account — the representation, the validity guarantee, and every form that makes new bytes — and the operations that live in the library are under sysl.text.

Fixed arrays and slices

Two sequence types where many languages have one.

typewhat it is
[N]Ta fixed array: N elements, a value, no header, length known while compiling
[]Ta slice: a view of elements someone else owns — { owner, pointer, length }
[]const Ta slice that may be read and not written

An array is its elements, so copying one copies all of them and passing one by value passes the whole thing. A slice names elements that live somewhere else, and copying a slice copies the three-word header rather than the data.

var a = [1, 2, 3]
var b = a

b[0] = 99

var v: []int = a[..]
v[1] = 88

print(a[0], a[1], b[0])
1 88 99

The disagreement in that program is the whole distinction: writing through b did not touch a because b is a copy, and writing through v did because v is a view.

Both carry a length, so every index is checked. Arrays and slices is the full account — writing one down, storage sized while running, the indexing and slicing rules, and what a view keeps alive.

Vectors

A fourth sequence shape, and the one that is not storage at all.

typewhat it is
<N>Ta vector: N lanes of T in a register, whose operators work on every lane at once

It holds the same values an [N]T holds, in the same order — the two type constructors differ by one bracket pair because a vector is an array that computes lane-wise:

val a: <4>f32 = [1.0, 2.0, 3.0, 4.0]
val b: <4>f32 = [10.0, 20.0, 30.0, 40.0]

print((a + b)[3], (a * 2.0)[0], (a * b).sum())
44 2 300

That + is one instruction doing four additions, and the 2.0 broadcasts into every lane. A lane is read by a constant index, which is the one subscript in the language not checked while the program runs — a register has no address to check against. A run of an array or a slice moves into a vector and back with xs.load(i) and xs.store(i, v), which are checked, because those have addresses.

A machine with no vector unit is not a special case: the back end turns a vector into as many registers as it needs, or into ordinary scalar operations, so <4>f32 compiles everywhere sysl compiles. Vectors is the full account — masks and select, the reductions, and writing one kernel that is compiled for more than one register width.

Structs

A named product type. Fields are declared one per line, and a struct is a value — assigning one copies it.

struct Point
    x: int
    y: int
end Point

var p = Point(1, 2)
var q = p

q.x = 99

print(p.x, q.x)
1 99

Structs may carry methods, an invariant, and a visibility modifier per field. A struct may also be declared opaque, which withholds its layout from everyone outside its own module — a different axis from visibility, covered under modules.

Enums

Two shapes under one keyword. A simple enum is a set of named discriminants with an underlying integer type; a data enum gives variants payloads, making it a sum type.

enum Color
    Red
    Green
    Blue

enum Shape
    Circle(r: real)
    Rect(w: real, h: real)

area(s: Shape) -> real = s match
    Circle(r)  -> 3.14159 * r * r
    Rect(w, h) -> w * h

print(int(Green), area(Circle(1.0)), area(Rect(2.0, 3.0)))
1 3.14159 6

Option[T] and Result[T, E] are ordinary data enums declared in the standard library, with no compiler privileges — which is why you can write your own and have it work identically.

A variant belongs to its enum

Two enums in one module may each name a variant Failed, and neither has to be renamed. What a bare name means is settled where it is used, by the type expected there — an argument, an annotated binding, a return and a field all supply one, so the short form is what you normally write:

enum Shape
    Circle(r: int)
    Square(side: int)

enum Hole
    Circle(r: int)
    Slot(len: int)

area(s: Shape) -> int = s match
    Circle(r)    -> 3 * r * r
    Square(side) -> side * side

depth(h: Hole) -> int = h match
    Circle(r) -> r
    Slot(len) -> len

val s: Shape = Circle(2)

print(area(s), depth(Circle(5)))
12 5

Where two enums answer and nothing says which, that is a diagnostic rather than a quiet choice — a construction that picked the first-declared enum would be a line whose meaning changed when somebody added an unrelated enum above it:

enum Shape
    Circle(r: int)
    Square(side: int)

enum Hole
    Circle(r: int)
    Slot(len: int)

var s = Circle(1)

print(1)
'Circle' is a variant of 'Shape' and 'Hole', and nothing here says which — qualify it, as 'Shape.Circle'

Shape.Circle(1) is what that line wants. The qualified form works at a construction exactly as it works in a pattern.

This is Rust’s arrangement — a variant is namespaced under its enum — without Rust’s use site, where Link::Failed is required everywhere unless a scope opts into use Link::*. A variant still may not share a name with a constant, a val, a module var or an extern variable: two variants of a name are told apart by the enum they belong to, and a variant and a constant have nothing to be told apart by.

Type aliases

type Name = Existing introduces a second spelling for a type, interchangeable with the first. It creates no new type and no checking: an alias is for shortening a name that has grown long, not for distinguishing two uses of the same representation.

When you want a genuinely distinct type — one the compiler will not let you confuse with its base — that is a constrained type, written with new, and it is covered under contracts.

Function types

A callable’s type is written with Fn:

apply(f: &Fn(int) -> int, x: int) -> int = f(x)

print(apply(n -> n * 3, 7))
21

A named function used where a callable is expected is the capture-free case of the same thing — there is no separate “function pointer” concept to learn. A raw C function pointer, for a foreign boundary, is spelled *extern(A) -> R and is covered under the foreign interface.

A parameter passed by name

A parameter written with the arrow and nothing on its left takes an expression the call does not evaluate, and the body evaluates at each use:

static var built: int = 0

message() -> int
    built += 1
    42

log(on: bool, m: -> int)
    if on then print(m)

log(false, message())
log(true, message())
print(built)
42
1

message() ran once, not twice: the first call never evaluated its argument. That is the form’s whole purpose — an argument a callee may not want should cost nothing to offer.

Each use is an evaluation, because each use is a call — so a body that names the parameter twice runs the argument twice. A body wanting one evaluation binds it to a val first.

It costs nothing at runtime. x: -> T has the type Fn() -> T, so it lowers to a bounded type parameter exactly as the ordinary bare arrow does — one specialized copy per call site, called directly, with no allocation.

x: () -> T is the neighbouring form and keeps its meaning. Same type, different call site: there the caller constructs the callable and the body calls it.

twice(f: () -> int) -> int = f() + f()

print(twice(() -> 21))
42

Search

Esc
to navigate to open Esc to close