Skip to content

Type System

Sysl has value types, reference-counted refs (&T), and raw pointers (*T) as three orthogonal ways to use the same data. Most types are fixed-width and laid out without hidden overhead.

TypeAliasSizeDescription
i81 bytesigned 8-bit integer
i16short2 bytessigned 16-bit integer
i32int4 bytessigned 32-bit integer
i64long8 bytessigned 64-bit integer
u8byte1 byteunsigned 8-bit integer
u16ushort2 bytesunsigned 16-bit integer
u32char, uint4 bytesunsigned 32-bit integer (Unicode codepoint)
u64ulong8 bytesunsigned 64-bit integer
f32float4 bytesIEEE-754 single-precision
f64double8 bytesIEEE-754 double-precision
bool1 bytetrue / false
unit0 bytesno value
string16 bytesfat pointer {ptr: *u8, len: i64}

All integer arithmetic wraps at the declared type width. There is no implicit integer promotionu8 + u8 produces u8, not int. To avoid wrapping, widen explicitly: int(a) + int(b).

  • Unsigned types wrap via modular arithmetic: u8(255) + u8(1)0
  • Signed types wrap via two’s complement: int(2147483647) + 1-2147483648
  • i64 / u64 use the full register width and do not truncate

This matches Go / Rust / Swift. C-style implicit integer promotion is not used.

For explicit overflow behaviour, use the polymorphic intrinsics. All take two operands of the same integer type and return the same type:

IntrinsicBehaviour
wrapping_add(a, b)Two’s-complement wrap on overflow
wrapping_sub(a, b)Two’s-complement wrap on underflow
wrapping_mul(a, b)Low bits of the true product
saturating_add(a, b)Clamp to the type’s MAX (MIN for signed underflow)
saturating_sub(a, b)Clamp to the type’s MIN (0 for unsigned)
saturating_mul(a, b)Clamp to MAX/MIN on overflow
var a: u8 = 200
var b: u8 = 100
wrapping_add(a, b) // 44 (300 & 0xFF)
saturating_add(a, b) // 255 (clamped to u8 MAX)
saturating_sub(b, a) // 0 (clamped to u8 MIN)
*T // raw pointer (8 bytes, unmanaged)
*T not null // raw pointer constrained to be non-null at produce sites
&T // ref-counted reference (8 bytes, auto-freed at rc=0)
[n]T // fixed-size array (n * sizeof(T), stack-allocated)
[]T // slice: {ptr, len, cap} (16 bytes)
&[]T // ref-counted heap array (from `new [n]T`)
(T1, T2, T3) // tuple (desugars to anonymous struct)
(P1, P2) -> R // function pointer / closure (16 bytes: {func_ptr, env_ptr})

*T not null is a subtype of *T with a runtime check: every assignment, parameter bind, return, or cast that produces a *T not null value verifies the pointer is non-null. A null value traps at the produce site. The check is inserted via the same where-predicate mechanism used for user-defined predicates (a synthesized checker function per inner type).

*T not null is pointer-compatible with *T, so it can be passed anywhere a *T is expected.

struct Point
x: int
y: int
struct Node
value: int
next: *Node // recursive via pointer

A struct may declare one or more invariant <bool> clauses among its fields. Each invariant is type-checked at declaration time (must be bool) and re-evaluated at every check site on a value of that struct type. Bare field names are in scope; module-level consts and globals are also in scope.

struct Account
balance: int
limit: int
invariant balance >= -limit
struct Range
lo: int
hi: int
invariant lo <= hi
invariant hi - lo <= 100 // multiple clauses: all must hold

A violating mutation traps via the standard contract-check path. Invariants fire on:

  • construction-site init: var a: Account = Account(0, 100)
  • whole-value reassignment: a = Account(50, 100)
  • field assignment: a.balance = -200 (also through pointer/ref: (*p).balance = -200)
  • field compound assignment: a.balance -= 50

The invariant expression is re-evaluated at each check site, so an invariant that mentions a side-effecting expression re-runs those side effects. Use plain field reads.

--no-contracts strips the runtime check while keeping the type check.

Simple enums are integer constants with auto-incrementing values:

enum Color
Red // 0
Green // 1
Blue = 10 // explicit value
Yellow // 11 (auto-increment)

Access via Color.Red. Simple enums can also serve as distinct types in type positions; bare variant names work as constructors:

enum ParseError
EmptyInput
BadDigit
Overflow
parse(s: string) -> Result[i64, ParseError]
if len(s) == 0 then return Err(EmptyInput)
Ok(42)

Variants can carry data (Rust-style tagged unions):

enum Shape
Circle(radius: int)
Rect(w: int, h: int)
Empty

Construction:

s = Circle(5)
e = Empty // bare name
e2 = Shape.Empty // qualified form also works

Pattern matching:

s match
Circle(r) -> r * r * 3
Rect(w, h) -> w * h
Empty -> 0
Circle(r) if r > 10 -> 1 // guard with binding

Exhaustiveness. A match on a data-enum value must cover every variant, or include a wildcard _ -> ... or else -> ... default. Missing variants produce a compile error listing them. Guarded arms (Circle(r) if r > 0 -> ...) do not count toward exhaustiveness since the guard may be false. Non-enum matches (on integers or strings, for example) do not require exhaustiveness — the user is responsible for covering their own domain.

Heap-allocated enums (new on variants). new Variant(args) heap-allocates an enum value and returns a ref-counted &EnumType. Enables recursive data structures:

enum Expr
Lit(value: int)
Add(left: &Expr, right: &Expr)
eval_expr(e: &Expr) -> int
*e match
Lit(v) -> v
Add(l, r) -> eval_expr(l) + eval_expr(r)

Memory layout. {tag: i32, padding, data: union-of-variants}. sizeof(EnumType) returns total size including tag and padding.

Two orthogonal modifiers compose, plus optional runtime checks:

type Callback = (int) -> int // plain alias (transparent)
type Age = int within 0..150 // subtype: base-compatible, range-checked
type Meters = new f64 // derived: nominally distinct
type SafeAge = new int within 0..150 // derived + constrained
type Even = int where value % 2 == 0 // arbitrary predicate
type PosEven = int within 0..100 where value % 2 == 0 // within + where combined
FormBase-compatible?Runtime check?
type A = Byesno
type A = B within ryesrange
type A = B where pyespredicate
type A = new Bnono
type A = new B within rnorange
type A = new B where pnopredicate
type A = [new] B within r where pboth

Range syntax. Bounds must be numeric literals (including char, which is u32) or references to a const; a unary sign is allowed.

SyntaxMeaningExample
lo..hiinclusive [lo, hi]type Age = int within 0..150
lo..<hiexclusive upper [lo, hi)type Prob = f64 within 0.0..<1.0

Where predicates. where <bool-expr> attaches a boolean predicate. Inside the predicate, value refers to the value being checked. The predicate runs at every produce site (assignment, parameter bind, return, explicit cast). Unlike within, where predicates are not compile-time folded — even for literal arguments.

Compatibility.

  • Subtypes (without new) are transparently compatible with the base type; runtime checks fire on each produce site.
  • Derived types (with new) are nominally distinct from the base and from other derived types over the same base. Mixing them in arithmetic or assignment is a compile error. Use Meters(3.0) to wrap and f64(m) to unwrap. Arithmetic between two values of the same derived type yields that derived type.
  • Out-of-range literal bounds are caught at compile time; runtime violations trap.

Plain aliases are transparent names for existing types:

type IntPtr = *int
type Callback = (int) -> int

Range-constrained types and simple enums expose their metadata through ::-suffixed attributes. They work like Ada’s 'Attr notation, retargeted to sysl’s :: separator.

type Age = int within 0..150
enum Day { Mon; Tue; Wed; Thu; Fri; Sat; Sun }
Age::First // 0
Age::Last // 150
Age::Range // for-loop sugar (see below)
Day::First // Mon (value 0)
Day::Last // Sun (value 6)
Day::Image(d) // "Tue" for d = Day.Tue
Day::Value("Tue") // Day.Tue parses a string back to its variant
Day::Pos(d) // 1 for d = Day.Tue
Day::Val(2) // Day.Wed variant at position n
Day::Succ(d) // Wed for d = Day.Tue
Day::Pred(d) // Mon for d = Day.Tue
Age::Succ(a) // a + 1, traps if a is already 150
Age::Pred(a) // a - 1, traps if a is already 0
Age::Valid(raw) // bool — true iff raw is in 0..150, never traps
AttributeApplies toResult
T::Firstwithin-constrained int, simple enumlower bound / first variant’s value
T::Lastwithin-constrained int, simple enumupper bound (minus 1 if ..<) / last variant
T::Rangesameonly valid in for i in T::Range — inclusive scan
T::Image(x)simple enum, constrained numeric typevariant name string / str(x) for numerics
T::Value(s)simple enumvariant whose name equals s; traps on no match
T::Valid(x)within-constrained int, simple enumbool — does x satisfy the constraint? never traps
T::Pos(x)simple enum0-based declaration position
T::Val(n)simple enumvariant at position n; traps on out-of-range
T::Succ(x)within-constrained int, simple enumnext value; traps at the upper end
T::Pred(x)within-constrained int, simple enumprevious value; traps at the lower end

Lowering. ::First and ::Last fold to compile-time constants. ::Valid on a within-int type folds to an inline x >= lo && x <= hi (or < hi for ..<). The rest lower to synthesized helper functions (__image_T, __value_T, __valid_T, __pos_T, __val_T, __succ_T, __pred_T) emitted once per target type.

Trapping. ::Pos / ::Value on an unknown input, ::Val on an out-of-range position, ::Succ past the upper bound, and ::Pred past the lower bound all trap via the standard contract-check path. ::Valid is the non-throwing complement — it returns a bool so the caller can branch:

if Age::Valid(raw) then
var a: Age = raw // safe: the range check will pass

::Value and ::Image round-trip: T::Value(T::Image(x)) == x for every variant x.

::Range is for-loop sugar. for i in T::Range body parses as for i in T::First..T::Last body. for i in reverse T::Range body desugars the other way, for i in T::Last downTo T::First body. Using ::Range outside a for-loop is a compile error. See Statements and Control Flow for the loop forms.

Limitations.

  • Float-based within types do not yet support ::First / ::Last (and therefore none of the others).
  • ::Valid is not yet stripped by --no-contracts since it is introspection, not a contract trap. Every other ::* trap path is elided when contracts are disabled — invalid input then yields garbage (-1 for enum helpers, v+1 / v-1 past the bound for within ::Succ/::Pred).

The same struct definition supports three usage modes:

DeclarationTypeSemantics
var v = Point(10, 20)Pointstack-allocated, bitwise copy
val r = new Point(10, 20)&Pointheap-allocated, ref-counted
var p: *Point = &v*Pointraw, unmanaged
  • ref → value: not implicit (use .copy())
  • value → ref: new Point(v)
  • ref → ptr: &r (unsafe, no refcount change)
  • ptr → ref: always an error (can’t manufacture a refcount)
  • value → ptr: &v (address-of)
  • ptr → value: *p (dereference); also implicit for self only

The volatile qualifier prevents the compiler from optimising away, reordering, or coalescing loads and stores. Use it for MMIO registers and shared-memory variables.

volatile var mmio_status: u32 = 0
volatile var shared_flag: int
struct UartRegs
volatile status: u32
volatile data: u32
baud: int // non-volatile, normal optimisation allowed

The LLVM backend emits load volatile / store volatile. The TRISC backend is unaffected — it does not optimise loads/stores.