sysl

Generics

Type parameters, bidirectional inference, bounds checked at the definition, and monomorphization.

A type parameter list in square brackets makes a function, struct, or enum generic. A parameter name stands for a type not yet known, and may appear anywhere a type may — a parameter type, a return type, a field type, a variant payload, a local annotation.

id[T](x: T) -> T = x

struct Pair[A, B]
    first: A
    second: B

var p = Pair(1, "one")

print(id(7), id("hi"), p.first, p.second)
7 hi 1 one

Three further declarations take parameters without declaring a type of their own: an impl block, whose subject is a generic type or a composed shape applied to them; a member, which may be generic over types of its own beyond its type’s; and a trait, which is then a family of promises rather than one. All three are on traits.

[] means type application in a type, indexing in an expression

Square brackets are reused for two things, disambiguated by position, with no new token:

  • in a type, Box[int] and Result[Box[int], string] apply type arguments to a generic type, and nesting is ordinary;
  • in an expression, a[0] indexes, and never reads as a type application.

The reuse is unambiguous because a type and an expression never occupy the same grammatical slot. What it looks like it costs is explicit type arguments at a call head — id[int](7) reads as “index id by int, then call” — but only to the parser. The compiler resolves the head first, and a function is not a thing that can be indexed, so there is no second reading to protect:

id[T](x: T) -> T = x

print(id[int](7), id[string]("s"))
7 s

The nearest binding wins, which is the rule every call form follows. A local standing over a function’s name makes the brackets an index again, and its author is never told about a feature they did not reach for.

Writing the type arguments

Six heads take the list. A value argument is written exactly as a type one is, since the two share a list and a position:

writtenwhat it names
id[int](7)a function, qualified or not
chunk[8]()a value parameter — [const N: usize]
Pair[int, real](1, 2.5)a constructor, qualified or not
x.pick[int](3)a method — its own parameters, not the receiver’s — on any receiver, a bounded type parameter’s included
Maybe[int].Just(1)a variant, which is a construction of its type — qualified or not
Box[int].of(1)an associated function, called at the instantiation the list names — qualified or not
va_arg[int](ap)a special form: va_arg and ptr_cast

A method reached through a bound takes its list as one on a concrete receiver does. The list names the trait member’s own parameters, which is the only way to say what one is when no argument mentions it — and a trait’s default body writes it on self the same way:

trait Out
    one[A](self) -> int

    two(self) -> int = self.one[string]() + 1

struct Sink
    n: int

impl Out for Sink
    one[A](self) -> int = self.n

via[O: Out](o: *O) -> int = o.one[int]()

var s = Sink(41)
print(s.one[int](), via(&s), s.two())
41 41 42

An associated function called off an applied name is reached at that instantiation, exactly as it is through an alias of it. One with no receiver may have nothing else that could say what the type’s parameters are:

struct Of[T]
    x: T

    twice() -> int = 2

    of(x: T) -> Of[T] = Of(x)

type OfInt = Of[int]

val o = Of[u8].of(200)
print(OfInt.twice(), Of[int].twice(), Of[Of[string]].twice(), o.x + 55)
2 2 2 255

The list is the type’s, so a method is still refused there — a type in front of the dot is no value to call it on — and a member read without a call is told where the type is written instead:

struct Of[T]
    x: T

    get(self) -> T = self.x

val o = Of(3)
print(Of[int].get(o))
'get' is an instance method of 'Of' — call it on a value, not the type

A head reached through its module takes the list exactly where the bare name would, so a type from another module is instantiated without importing its name:

import sysl.math.complex

val z = complex.Complex[f32](1.5, 2.0)

print(z.re, z.im)
1.5 2

Inference is still what supplies them nearly everywhere, and a list inference would have found is noise. What earns the syntax is a signature neither direction of inference reaches — a kernel whose width is a value parameter it names in no argument and answers unit with:

add[const W: usize](a: []const f32, b: []const f32, out: []f32)
    var i: usize = 0

    while i + W <= a.len
        val l: <W>f32 = a.load(i)
        val r: <W>f32 = b.load(i)

        out.store(i, l + r)
        i += W
end add

val xs: [8]f32 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
val ys: [8]f32 = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]
var out: [8]f32

add[4](xs[..], ys[..], out[..])

print(out[0], out[7])
11 88

Written without the brackets there is nothing anywhere to say what W is, and the message says so rather than naming an annotation that cannot exist:

add[const W: usize](a: []const f32, out: []f32)
    val v: <W>f32 = a.load(0)

    out.store(0, v)

var xs: [8]f32
var out: [8]f32

add(xs[..], out[..])
'W' is in neither the parameters of 'add' nor its result

Two things stay inferred. A type pack — ..A — stands for a list of types rather than one and has no expression spelling, so its instantiation is always solved. And a bare arrow’s own parameter, which is the case below.

An associated function selected from the plain name of a generic type reads its type’s parameters from the annotation on the binding. The annotation reaches it through an unwrap() or an expect(…) as well, where the function answers an Option or a Result of what it makes: the binding says what the wrapper carries, and that is what settles the type. The call’s own arguments are read first, so where they settle the type the binding is checked against it as usual:

struct Gain[S]
    k: S

    of(x: real) -> Result[Self, string]
        if x > 1.0 then return Err("too loud")
        Ok(Gain(S(x)))
end Gain

val g: Gain[q15] = Gain.of(0.5).unwrap()
val h: Gain[q31] = Gain.of(0.25).expect("in range")

print(g.k, h.k)
0.5 0.25

A bare arrow is not in the list

f: T -> U is sugar for a bounded type parameter — the callable’s own type, which the compiler adds and no program writes. So a declaration carries more parameters than were written on it, and the list is the ones that were:

chain[A, B](x: int, f: int -> A, g: A -> B) -> B = g(f(x))

print(chain[int, int](3, n -> n * 2, n -> n + 1))
7

Two arrows, two added parameters, and [int, int] is still A and B. There is nothing to write for the other two: their argument is the closure’s own anonymous type, and the name the compiler gave them holds a character no identifier may contain.

Where this is felt is a parameter only a bound mentions, which inference does not reach and the list is the only way in — so a declaration taking a callback needs both halves to work at once:

trait Has[T]
    get(self) -> T

struct Box
    v: int

impl Has[int] for Box
    get(self) -> int = self.v

pick[B: Has[T], T, N](b: B, f: T -> N) -> N = f(b.get())

print(pick[Box, int, int](Box(4), n -> n + 1))
5

An address is the one position with no answer. &f[…] has no arguments to read the callable’s type off, and no way to write it, so a function taking a bare arrow has no address form and is refused by name rather than by a count:

chain[A, B](x: int, f: int -> A, g: A -> B) -> B = g(f(x))

val p = &chain[int, int]

print(1)
'chain' takes a bare arrow, so it is generic in the callable's own type — and an address has no arguments to read that from, nor any way to write it. Declare the parameter as a counted callable, '&Fn(…) -> …', to give it a type that can be named

The boxed spelling is what an address needs: &Fn(T) -> U is a type with a name, so a function declared with one is an ordinary generic and its address is written as any other.

Every type an annotation can spell can be written in the list. The parser reads each item as an expression first, so a subscript, T, *T, &T, Box[T] and (A, B) are what they always were; where that reading does not end the item at the , or ], it reads the item again as a type from the same place. That is what admits &sync T, []T, []const T, [N]T, a nested application and a boxed callable:

import sysl.buf.{Buf, buf}

struct Shared
    n: int

val shared = buf[&sync Shared]()
val views = buf[[]const u8]()
val rows = buf[[3]int]()
val nested = buf[Buf[&sync Shared]]()

print(shared.len(), views.len(), rows.len(), nested.len())
0 0 0 0

A type that is refused in an annotation is refused here with the same message, so a bare callable int -> int is told to be written &Fn(int) -> int. In a subscript, such a type is told it is not a value:

first(xs: []const int) -> int = xs[&sync Missing]
this is a type, and a value is wanted here

The address form came first: &f[T] names one instantiation of a generic function, and the case that earned it is a C callback whose signature mentions the type parameter nowhere. See the FFI reference.

Construction

Applying a generic type names a concrete instance: Box[int] is the type of a box of int, Pair[int, string] a pair. Constructing one is the ordinary construction, with the type arguments inferred from the arguments — Box(41), Pair(1, "one") — or written on the name, which means what the annotation means: Pair[int, real](1, 2.5) fixes the instantiation and checks the fields against it. The memory mode is the usual per-declaration choice: Box(41) is a value unless a &Box[int] is expected.

Inference is bidirectional

Type arguments are inferred, and from two directions.

From the arguments. id(7) infers T = int, and inference reaches through a generic construction, so every nested parameter is solved at once:

struct Box[T]
    v: T

id[T](x: T) -> T = x

var b = id(Box(id(5)))

print(b.v)
5

From the expected type, when the arguments cannot determine a parameter. A nullary generic has nothing in its argument list to fix T, so the declaration supplies it and the return type flows inward:

empty[T]() -> Option[T] = None

var e: Option[real] = empty()

e match
    Some(v) -> print("some", v)
    None    -> print("none")
none

The model is unification: each parameter is solved by matching the declared parameter and return types against the actual argument types and the expected type. When a parameter is left undetermined by both directions, that is a compile error asking for an annotation on the binding — never a silent default and never a stuck inference variable.

A literal is consulted last, because a literal has no type of its own to offer. It takes one from where it appears, and a parameter still being solved is not yet a place that can give it one. So what is already a type settles the parameter first, then the expected type, and a literal’s default only where nothing else reached it:

pick[T: Add](a: T, b: T, c: T) -> T = a + b + c

print(pick(1, 2, 250u8))
253

That is a u8 because one argument knew and two did not, while id(7) is still an int because none did. Once the parameter is a type the literals are read against it — the same order the operand rule uses inside an expression.

A construction built out of nothing but literals is consulted last for the same reason. Some(3) is a call rather than a literal, but the only thing that decided its Option[int] was an unsuffixed 3 — so the conclusion is worth exactly what the 3 was worth, and an argument carrying a type of its own outranks it whichever end of the call it is written at:

same[T: Eq](a: T, b: T) -> bool = a == b

var s: Option[usize] = Some(3)

print(same(s, Some(3)), same(Some(3), s), s == Some(3), Some(3) == s)
true true true true

An array literal and a generic struct reach it on the same rule, and it nests — Some(Some(3)) is built out of literals however far down you look.

Two things stop it, and each is the reader having said what they meant. A suffix anywhere inside fixes the width, so Some(3u8) has a type of its own and disagreeing with it is a diagnostic. And an ordinary call is not a construction: f(3) is spelled exactly as Some(3) is, and what comes back has nothing to do with the literal handed over, so it keeps the type it answered with.

f(n: int) -> Option[int] = Some(n)

var s: Option[usize] = Some(3)

print(f(3) == s)
'==' needs matching types, got sysl.Option[int] and sysl.Option[usize]

A parameter is solved to the type that was written. Where that is a transparent subtype, the parameter carries the subtype rather than the base it is stored as — and it does so however the call said which type it is at:

type Age = int within 0..150

widest[T]() -> T = T::Max

val written = widest[Age]()
val expected: Age = widest()

print(int(written), int(expected))
150 150

The routes have to agree, because from the reader’s side they are three ways of saying one thing. Writing Age at the call, handing an Age as an argument, and annotating the binding Age are each a way of naming the type this call is at; an answer that depended on which one was available would be an answer about the call rather than about the type.

That a transparent subtype is its base — an Age stands where an int is asked for, and the reverse — is unchanged, and holds for every value that flows. A type parameter is not a value: it is the name a body is written about, and the body may ask it something its base would answer differently. T::Max is the only such question there is.

null is not consulted at all — it waits, and what separates it from a literal is that it has no default to be consulted for. The argument that cannot contribute is set aside, the rest solve the parameter, and it is then read against what they said:

two[T](a: *T, b: *T) -> bool = a == b

var x: int = 3

print(two(&x, null), two(null, &x))
false false

Either order answers alike, since waiting is not queueing — and setting it aside cannot lose the solution, because an argument with no type of its own has nothing to unify. Where nothing else reached the parameter it is refused rather than defaulted: inference does not invent a pointee.

one[T](a: *T) -> bool = true

print(one(null))
'null' takes its type from its context, and there is none here

A callable waits for the same reason and contributes on the way back. A closure carries no type of its own — its parameters come from the position it is written in — so it is set aside with the rest; what it turns out to be then joins the solution, and the callables still waiting are read against it. So one arrow’s result can be what another arrow takes, and which of them was declared first is not part of the answer:

forward[T](f: int -> T, g: T -> int) -> int = g(f(0))

backward[T](g: T -> int, f: int -> T) -> int = g(f(0))

print(forward(n -> n + 1, n -> n * 2), backward(n -> n * 2, n -> n + 1))
2 2

The two declarations differ in nothing but the order of their parameters, and each round reads the arguments it can — so an argument is waiting on what it needs rather than on its turn.

A parameter that names no type parameter is not part of the question, and its argument is checked against it exactly as a plain callee’s is:

at[T](x: T, n: usize) -> string = str(n)

print(at("v", 7))
7

That is worth saying because inference has to look at the arguments before it knows what anything is, which would otherwise cost a generic callee the rules that need an expected type — a parameter’s type fixing an unsuffixed literal, and the coercions to &T and to a trait object. A declaration having a T somewhere does not make its usize any less a usize.

Members and associated functions

A method never asks the question. Its receiver already is a Box[int], so the type’s arguments are read rather than solved.

An associated function has no receiver, which puts it in the position a generic free function is always in — so it is inferred by exactly the rule above and needs no machinery of its own:

struct Box[T]
    v: T

    of(x: T) -> Box[T] = Box(x)

var b = Box.of(41)

print(b.v)
41

Self in the signature is the type applied to its own parameters, so writing -> Self and writing -> Box[T] infer alike.

A type parameter that stands for a generic type brings that type’s arguments with it, and this is the one place there is nothing to infer: T.origin() where T is already Box[int] is asking Box[int], not asking Box and hoping the call says which. So a generic body reaches an associated function of a generic type with no annotation, which is what makes T.zero() usable in a body that has met neither a width nor a Complex:

trait Origin
    origin() -> Self

impl Origin for int
    origin() -> int = 0

struct Box[T]
    v: T

impl[T: Origin] Origin for Box[T]
    origin() -> Box[T] = Box(T.origin())

seed[T: Origin](xs: []const T) -> T
    var s = T.origin()

    s

var bs: []Box[int] = [Box(7)]

print(seed(bs).v)
0

Self inside the type’s own body is the same case reached from inside, and needs no annotation either. What still has to be told is a bare type name — Box.origin() written out applies Box to nothing, so there the expected type is the whole of what settles it.

A member’s own type parameters are inferred the same way. The receiver says what the type’s arguments are and nothing about the member’s, which leaves those exactly where the rule already reaches:

struct Pair[A, B]
    first: A
    second: B

struct Box[T]
    v: T

    with[U](self, x: U) -> Pair[T, U] = Pair(self.v, x)

var b = Box(1)
var p = b.with("x")

print(p.first, p.second)
1 x

The two lists are held to their bounds separately and under the name each was written in. That they must not collide is the one thing the two-list form adds, and it is settled by refusing a member that spells one of its own the way its type spells one of its.

Bounds

A type parameter is bounded by a trait, and the bound is what the body of a generic is allowed to assume about the parameter.

An unbounded parameter permits only what every type supports

With no bound, T may be used only for the operations every sysl value has — which, because of the memory model, is a genuinely useful set: copied, assigned, passed, returned, and stored in a struct field, an enum payload, an array, or a slice.

That set is exactly id[T], Box[T], Pair[A, B], and every other container: they move data around without inspecting it, and they need no bound.

struct Box[T]
    v: T

keep[T](x: T) -> Box[T] = Box(x)

var a = keep(5)
var b = keep("hi")

print(a.v, b.v)
5 hi

Every sysl value is copyable — assignment copies, and copying a value holding a &T retains it — so there is no Copy bound to write, ever. That is a real simplification over Rust, where T is move-by-default and T: Copy / T: Clone litter generic signatures. Here, “hold and hand along any T“ is the free, unmarked baseline.

What an unbounded T may not do is anything that assumes structure: no operator, no method call, no field access, no index. Each is a capability some types have and others do not, so each requires a bound that guarantees it — and each is refused at the definition, naming the bound to write:

sum[T](a: T, b: T) -> T = a + b

print(sum(2, 3))
'+' needs 'T: sysl.Add'

A subscript is among them, because a subscript is Index‘s one method, so it is asked of the bounds exactly as a dot call is.

A field is the exception that proves the rule. Every other unlicensed use names the bound that would allow it — the diagnostic’s whole job is to say what to write. A field names none, because a trait promises behaviour and a field is layout, so no bound could ever supply one:

first[T](x: T) -> int = x.v

print(1)
'T' is a type parameter, so it has no fields to read — a field is layout, and no trait declares a property 'v' that a bound could promise instead

It is therefore settled outright at the definition rather than deferred to the types that turn up: first[T](x: T) = x.v is wrong even if every call happens to pass a type with a v. Reaching a value’s data through a generic means going through a member the bound declares, which is also what lets two types satisfy one bound while storing the value differently.

x.v is spelled like a field and need not be one, so the diagnostic is reached only after looking for a property of that name: a property is behaviour, a trait may declare one, and reading it through a bound is as ordinary as calling a method.

A bound is a trait, written [T: Trait]

sum[T: Add](a: T, b: T) -> T = a + b

smaller[T: Ord](a: T, b: T) -> T = if a < b then a else b

print(sum(2, 3), smaller(9, 4))
5 4

a + b inside sum type-checks because T: Add promises the operator; drop the bound and sum fails at its own definition, pointing at the line that made the unsupported assumption. That is the whole payoff over the template model: the error lands on the definition that is wrong, not on some caller three files away that instantiated it with the wrong type. It is the Swift/Kotlin/Scala consensus — all three check bounds at the definition, and only C++ defers to instantiation.

Operators are available through bounds because operators are trait methods: + is Add, < is Ord, == is Eq.

Multiple bounds join with +: [T: Ord + Hash] requires both. The + reads unambiguously in a bound position, since it stands between trait names rather than values.

A bound is the trait applied, so it carries the trait’s own type arguments where it has any:

writtenmeans
[X: Sink[int]]the body’s x.put(…) takes an int
[X: Into[Y], Y]and Y is solved at the call
[X: Into[Y], Y: Display]a body that may also print what the conversion yields

The arguments are ordinary types, which is what lets one name another parameter of the same declaration — and what a body may then do with that parameter is what its bounds promise. Inference does not run backwards through a bound: a parameter appearing only there is solved from the result type or annotated.

A bound also brings the trait’s associated types into reach, written T::Name. They are the case the table above cannot serve: an argument is written by whoever names the trait, so a type the implementation chooses has nothing to be written as. [S: Seq] and then S::Item is how a body names one without the caller having to spell it, and S::Item is abstract there — licensed to do exactly what the trait declared for it, exactly as S itself is.

It stands wherever a type stands, and the position worth showing is a callable’s. A bare arrow is sugar for a bound of its own, so f: S::Item -> N is a parameter whose type nobody in the program writes out: the implementation chose it, the bound reached it, and the closure at the call site is monomorphized against it without being boxed.

trait Walk
    type Item

    step(*self) -> Option[Self::Item]

struct Down
    n: int

impl Walk for Down
    type Item = int

    step(*self) -> Option[int]
        if self.n <= 0 then return None

        self.n -= 1
        Some(self.n)

first_of[S: Walk, N](s: *S, f: S::Item -> N) -> Option[N] =
    s.step() match
        Some(t) -> Some(f(t))
        None -> None

var d = Down(3)

print(first_of(&d, n -> n * 10).expect("a step"))
20

n is an int because Down said type Item = int, and first_of never mentions int at all.

A trait object satisfies a bound on the trait it dispatches through

A bound asks whether the members it names can be called on the value. A *Trait or a &Trait has forgotten which type it holds, but it carries a table of exactly those members — so it answers yes, and one generic function takes both a concrete type and the object erased from it:

trait Shape
    area(self) -> int

struct Rect
    w: int
    h: int

impl Shape for Rect
    area(self) -> int = self.w * self.h

total[T: Shape](x: T) -> int = x.area()

var o: &Shape = Rect(3, 4)

print(total(Rect(3, 4)))
print(total(o))
12
12

Nothing about monomorphization is bent to allow it. An object type is a concrete type — a pair of words — so the body is instantiated at &Shape exactly as it is at Rect, once each. What differs between the two instantiations is what x.area() compiles to: a direct call in one, an indirect call through the table in the other. That is the same difference the two instantiations would have had anyway, and it is decided the same way — by looking at the type the parameter was bound to.

It holds because of object safety rather than because of anything promised here. A trait with a member that cannot be dispatched — one mentioning Self away from its receiver, say — has no object at all in sysl, rather than an object missing that member. So a &Shape existing is already the proof that every member of Shape is reachable through it.

The one exception is a member that declares type parameters of its own, which is left out of the table rather than out of the object (traits). A bound promises every member and an object reaches only what its table holds, so an object does not stand at a bound on such a trait — and the refusal says which member and why, because without that it reads as a &Applies failing to implement Applies:

trait Applies
    tag(self) -> int
    apply[U](self, f: &Fn(int) -> U) -> U

struct N
    v: int

impl Applies for N
    tag(self) -> int = self.v
    apply[U](self, f: &Fn(int) -> U) -> U = f(self.v)

total[T: Applies](x: T) -> int = x.tag()

val o: &Applies = N(3)

print(total(o))
a bound promises every member and an erased value reaches only what its table holds

The value itself is unaffected — N(3).apply(…) is an ordinary call, and a bound taken on a type parameter reaches it at every instantiation. What has no answer is the erased form, and only for that member.

It follows for required traits with no rule of its own. trait Shape: Display puts Display‘s slots in the object’s table, and a bound on a trait is already satisfied wherever a bound on one that requires it is — so a show[T: Display](x: T) takes the same o.

What an object still does not satisfy is a bound on any other trait: the table is what answers, and a trait with no slots in it has nothing to answer with.

trait Shape
    area(self) -> int

trait Weighed
    weight(self) -> int

struct Rect
    w: int
    h: int

impl Shape for Rect
    area(self) -> int = self.w * self.h

heft[T: Weighed](x: T) -> int = x.weight()

var o: &Shape = Rect(3, 4)

print(heft(o))
'heft' requires its type parameter 'T' to implement 'Weighed', but &Shape does not

Satisfying a bound is not the same as being erasable again. A bound asks what may be called through a value; forming an object asks what may be assembled from its type, and a table is laid out from a type’s implementations. An object has none, so a &Shape cannot be turned into a &Display even though Display‘s slots are sitting inside its table — a run of slots in one table is not a table of its own ([traits]({{< ref “traits” >}})).

A type’s own parameters carry bounds too

The same bracketed list, in the same place, means the same thing on a struct or an enum — and that is where the type says what it assumes:

struct SortedPair[T: Ord]
    lo: T
    hi: T

    ordered(self) -> bool = self.lo < self.hi

var s = SortedPair(1, 2)

print(s.ordered())
true

It buys two things, worth stating separately.

Everything applying the type must supply it, wherever the application is written — a declared parameter, a result, a field of another type, a variant’s payload, a construction:

struct SortedPair[T: Ord]
    lo: T
    hi: T

struct P
    v: int

var s = SortedPair(P(1), P(2))

print(1)
'SortedPair' requires its type parameter 'T' to implement 'sysl.Ord', but P does not

Where the argument is itself a type parameter, the answer is what its own bounds promise, so a function taking a SortedPair[U] must bound U by at least what SortedPair asks. A bound is satisfied by a bound, one step out.

And the type’s members may assume it, so they are checked at their definition. That is what having somewhere to write the bound is for: a member of a generic type is walked once, with the parameters standing in for themselves, by the same pass that walks a bounded generic function — so a method calling something no bound licenses is reported on its own line whether or not anything instantiates the type. A generic type’s fields are laid out once the same way, which is what catches a field applying another bounded type to this one’s parameter.

A bound is declared once, at the type, and is in force everywhere its parameters appear — a member’s signature and body, a field’s type, a variant’s payload. It is not restated per member. Restating it is Rust’s rule and its own users regret it; declaring once is the Swift/Kotlin behaviour and the one that matches how the bound reads.

A bound may be written out of line, with where

The bracket list is one place to put a bound and it is not always the readable one. A declaration with several parameters and several bounds pushes its parameter list, its result and its body off to the right, and what the reader came for — what the function takes — is the part that moves. where puts the bounds after the signature instead:

show[T](x: T) -> string where T: Display = str(x)

print(show(42))
42

It is the same bound in a different place, and nothing downstream can tell the two apart. A clause is folded into the list the brackets fill, so show[T: Display](x: T) and the form above are one declaration written two ways — same inference, same refusals, same monomorphization. Which to write is a question about the line, not about the meaning.

The two may be mixed, which is what makes the choice per-bound rather than per-declaration: the short obvious constraint stays in the brackets, and the long one that would crowd the signature goes below it.

same[T: Eq](a: T, b: T) -> string where T: Display =
    if a == b then str(a) else "different"

print(same(3, 3))
print(same(3, 4))
3
different

The clause is a comma-separated list, and each entry joins its traits with + exactly as a bracketed bound does. It goes at the end of the header line — after the result type, before the = or the indented block — and it is written the same way on a function, a member, a struct, an enum, a trait and an impl:

struct SortedPair[T] where T: Ord
    lo: T
    hi: T

    ordered(self) -> bool = self.lo < self.hi

var s = SortedPair(1, 2)

print(s.ordered())
true

A clause may only bound a parameter the declaration actually declares, and saying otherwise is refused where it is written rather than at some later use:

show[T](x: T) -> string where U: Display = str(x)

print(show(1))
this declaration has no type parameter 'U', so a 'where' clause has nothing to bound — it takes 'T'

A value parameter is the one case that reads as though it ought to work and cannot. [const N: usize] already states a type, and that type is what the argument must be rather than a promise about what it implements — a value implements no trait, so there is nothing for a clause to add:

sum[const N: usize](xs: [N]int) -> int where N: Display = 0

print(sum([1, 2]))
'N' stands for a value rather than a type, and a value implements no trait — its 'const' declaration is where the type its argument must have is written

where is not a reserved word, and the two jobs it already had are untouched. It introduces a constrained type’s predicate in a type declaration, where it follows a type rather than a signature, and it remains an ordinary name everywhere else — so a program may still call something where:

type Even = new int where value % 2 == 0

where(n: int) -> int = n + 1

var e = Even(4)

print(int(e), where(1))
4 2

A parameter may carry a default

A parameter of a trait, a struct, or an enum may name the type to use where a use leaves it out:

struct Pair[A, B = A]
    x: A
    y: B

total(p: Pair[int]) -> int = p.x + p.y

print(total(Pair(1, 2)))
3

The bound comes first and the default last — [R: Show = Self] — and either may be written without the other. Four rules govern them:

  • Defaults are filled left to right, each resolved under the arguments already fixed, so a default may name a parameter written before it, and naming one written after it is the forward reference it looks like.
  • They are a suffix. A parameter with no default may not come after one that has, because arguments are written in order and nothing could leave out the earlier one and still supply the later.
  • The filling happens before anything is keyed on the arguments, so Pair[int] and Pair[int, int] are one instantiation rather than two that happen to have the same fields.
  • A default is exposed like a field. A public declaration may not default to a type that reaches less far than it does, or a caller who leaves the argument out ends up holding something they could not have written and cannot name. And a default may not lead back to the declaration it belongs to, directly or through another’s.

A value parameter may carry one too, and it is the argument a use would have written in its place — so the same four rules govern it. It fills the same gap, counts toward the same arity and sits in the same suffix, a type default and a value default may share one list, and a value default may be a constant expression over the value parameters written before it:

struct Ring[T, const N: usize = 4]
    items: [N]T

struct Pad[const M: usize, const N: usize = M * 2]
    a: [M]int
    b: [N]int

val r: Ring[int] = Ring([1, 2, 3, 4])
val p: Pad[2] = Pad([1, 2], [3, 4, 5, 6])

print(r.items.len, p.b.len)
4 4

Ring[int] is Ring[int, 4] — one instantiation, filled before anything is keyed. The default has to fit the parameter’s declared type, exactly as a written argument does: [const N: u8 = 300] is refused with “the default for ‘N’ does not fit byte: 300”.

Self is the case the feature exists for. In a trait’s default it means the implementing type, exactly as in a method’s signature — so impl Scale for P is the impl Scale[P] for P it reads as, and [T: Scale] asks for Scale[T]. A struct and an enum have no implementing type, so Self in one of their defaults is refused. Neither has a trait object: an object has forgotten which type it holds, so a default of Self has nothing to name and the argument is written out.

Only those three declarations may carry one. A function’s, a method’s, and an impl block’s type parameters are solved from what they are given rather than written where they are used, so there is no argument list with a gap for a default to fill:

f[T = int](x: T) -> T = x

print(f(1))
'T' is a type parameter of the function 'f', whose type parameters are solved from what it is given rather than written where it is used — so '= int' has nothing to stand in for

What would be useful there is a fallback for an inference that found nothing, which is a different feature; f[T = int](x: T) is refused rather than quietly meaning that.

A value parameter of a function is solved from an array’s length exactly as a type parameter is from an argument’s type, so its default is refused in the same words:

total[const N: usize = 3](xs: [N]int) -> usize = N

print(total([1, 2]))
'N' is a value parameter of the function 'total', whose type parameters are solved from what it is given rather than written where it is used — so '= 3' has nothing to stand in for

Converting through a parameter

A conversion is the one capability with no bound to promise it, because a conversion is between the concrete scalar kinds rather than something a trait declares. Both directions are written, and both are checked at each instantiation rather than at the definition:

low[T](x: T) -> u8 = u8(x)

make[T](b: u8) -> T = T(b)

var n: int = make(7)

print(low(321), n)
65 7

T(b) is a conversion written where the parameter’s name stands, resolved at each instantiation, so the two directions of one conversion are one rule. An instantiation at a constrained subtype or a simple enum takes that type’s own checked cast, since the scalar conversion has no meaning for either and the form written under the type’s name does — so T(x) at an Age is the Age(x) a reader would have written, trap included.

Construction is deliberately not among the forms it reaches. A struct’s positional constructor takes a field list rather than a value, and a generic body filling in an unknown struct’s fields by position is not something to arrive at by accident:

struct P
    v: int

make[T](b: u8) -> T = T(b)

var p: P = make(7)

print(p.v)
cannot convert byte to P

T(b) stays a conversion at every instantiation, so at a struct it is refused exactly as u8(x) at one is — naming the struct, and not quietly becoming a constructor. What a container that wants to build a T reaches for is a bound that says so.

The parameter wins over a declaration of the same name, which closes an inconsistency rather than opening one: var y: T inside a [T] body has always meant the parameter, so T(x) one line later means it too.

Monomorphization

Each distinct set of type arguments produces its own specialized function or aggregate. id called at int and at real emits two functions; a Box[int] and a Box[string] are two distinct layouts. The IR carries exactly one definition per instantiation, not one per call.

That is why bounds can be checked once at the definition yet lower to direct, monomorphic code with no dictionary passed at runtime.

  • The cost is code size, the standard monomorphization tradeoff — C++ templates and Rust generics make the same one. It is the right default for a systems language, where the direct, inlinable call matters and the dynamic path is available when one copy is preferable.
  • Recursion is fine. A recursive generic function recurses at a fixed instantiation, so it monomorphizes like any other.

A nested function may be generic

A function declared inside a body takes type parameters, bounds and the bare-arrow sugar exactly as a top-level one does, and each call instantiates it the same way. What it adds is the frame: every instantiation is one more function over the environment its block shares, so each reads and writes the body’s variables as any nested function does.

tally() -> int
    var total = 0

    noted[T: Display](x: T, n: int) -> string
        total += n
        s"$x"

    print(noted("a", 2), noted(true, 3), noted(1.5, 4))
    total

print(tally())
a true 1.5
9

Two bodies may each declare a nested generic of one name: an instantiation is named for the frame it belongs to as well as for its type arguments, so the two are never one function. A type argument the call cannot settle is written out, sized[u32](), and a bound is held at the call:

struct Point
    x: int

describe() -> string
    shown[T: Display](x: T) -> string = s"<$x>"

    shown(Point(1))
'shown' requires its type parameter 'T' to implement 'sysl.Display', but Point does not

Variance does not arise

There is no variance question in sysl, by construction. Variance is about when G[A] may stand in for G[B], and that needs a subtyping relation to be interesting — which sysl does not have among concrete types. There is no inheritance, and a trait bound is a constraint rather than a supertype relation between values.

trait Animal
    noise(self) -> string

struct Cat
    n: string

impl Animal for Cat
    noise(self) -> string = "meow"

struct Box[T]
    v: T

hear(b: Box[&Animal]) -> string = b.v.noise()

var c = Box(Cat("ada"))

print(hear(c))
'b' of 'hear' is Box[&Animal], but Box[Cat] was given

Box[Cat] and Box[&Animal] are simply unrelated types. That deletes an entire category of design difficulty that afflicts languages with nominal subtyping, and it should stay deleted: polymorphism over a set of types is expressed by a bound or a trait object, never by a covariant container.

A parameter may stand for a value

A type parameter stands for a type. A parameter written const stands for a value — which is what lets one declaration cover every array length:

total[const N: usize](xs: [N]int) -> int
    var t = 0
    for i in 0..<N do t = t + xs[i]
    t

var a: [3]int = [1, 2, 3]
var b: [5]int = [10, 20, 30, 40, 50]

print(total(a))
print(total(b))
6
150

N is inferred from the argument exactly as a type parameter is: matching [3]int against [N]int binds N to 3, the way matching Box[int] against Box[T] binds T. Inside the body N is an ordinary usize — it can be looped to, computed with, and passed on.

total at N = 3 and at N = 5 are two functions, the same way id at int and at real are two: the value joins the type arguments the instantiation is keyed on, so the length is a constant inside each copy.

It is the same const as everywhere else

A constant is declared const NAME: Type = expr. A value parameter is that declaration with the initializer left for the caller, so [const N: usize] is existing grammar in a new position rather than a new idea.

The marker is not decoration. [N: usize] on its own is indistinguishable from a bounded type parameter — [T: Ord] has the same shape — and only name resolution could tell them apart, by asking whether the thing after the colon is a trait or a type. A trait name misspelled into a type name would then silently change what kind of parameter it is.

Which values

Integers, bool, char, and a simple enum’s variants. A value parameter puts a value into a type’s identity, so the compiler has to decide when two of them are the same value and has to write one into a mangled name — and each of these compares and mangles.

A type declares its value parameters the same way, and its arguments are written out rather than inferred, because a type has no call to infer them from:

struct Buf[const N: usize]
    data: [N]byte

struct Flag[const B: bool]
    v: int

var buf: Buf[4] = Buf([1, 2, 3, 4])
var on: Flag[true] = Flag(1)

print(buf.data.len, on.v)
4 1

Buf[2] and Buf[4] are two types with two layouts, and neither stands where the other is wanted.

An argument may be any constant expression — a const, arithmetic over one, or a call run while compiling — in a type and in a call alike (declarations.md § Calls where a type is read):

struct Ring[const N: usize]
    data: [N]byte

double(n: usize) -> usize = n * 2

const Base: usize = 2

var r: Ring[double(Base) + 1] = Ring([0; 5])

print(r.data.len)
5

A member reads the parameter as an ordinary value of its declared type, and the two lengths are two bodies rather than one compiled at whichever came first:

struct Buf[const N: usize]
    used: usize

    room(self) -> usize = N - self.used

var a: Buf[8] = Buf(3)
var b: Buf[2] = Buf(1)

print(a.room())
print(b.room())
5
1

A simple enum — one whose variants carry nothing — gives a parameter that takes one of its variants. The parameter’s declared type says which enum, so the variant is written bare, exactly as it is where an expected type names the enum; Level.High names the same value:

enum Level
    Low
    High

struct Gauge[const L: Level = Low]
    reading: int

    alarm(self) -> bool = L == High

var quiet: Gauge = Gauge(3)
var loud: Gauge[High] = Gauge(9)
var same: Gauge[Level.High] = loud

print(quiet.alarm(), loud.alarm(), same.reading)
false true 9

Gauge[Low] and Gauge[High] are two types, and the default makes a bare Gauge the first. A variant of any other enum is not a value of this one, bare or qualified:

enum Level
    Low
    High

enum Tide
    Low
    Slack

struct Gauge[const L: Level]
    reading: int

var g: Gauge[Slack] = Gauge(1)
print(g.reading)
'Slack' is not a variant of Level

An enum whose variants carry values cannot be a value parameter’s type — its value is more than the tag a symbol can spell — and neither can a float or a string:

enum Shape
    Dot
    Circle(r: int)

struct Holder[const S: Shape]
    n: int

print(1)
'S' is a value parameter, and Shape cannot be the type of one

Floats are excluded: NaN != NaN under the ordinary comparison, which would make a type unequal to itself. Strings are excluded until two spellings of one text are one value.

What may be written with N

N may stand as an array’s length, and a body may compute with it freely. What neither may do is carry the result of a computation into a type:

f[const N: usize](xs: [N]int) -> [N + 1]int = xs

print(1)
this length does arithmetic on 'N'

Deciding that N + 1 and 1 + N are one type — and that 2 * N and N + N are — is type-level arithmetic, which is a feature of its own.

Writing a type parameter where a length belongs is refused for the mirror reason, since a length is a value:

f[T](xs: [T]int) -> usize = 0

print(1)
'T' is a type parameter, and an array's length is a value rather than a type

What it is for: a fixed array renders

impl[const N: usize, T: Display] Display for [N]T is one block covering every array there is, which is why print takes one:

var a: [3]int = [1, 2, 3]
var b: [2]string = ["x", "y"]

print(a)
print(b)
[1, 2, 3]
[x, y]

Before this, a length was part of a type’s shape: [2]T and [3]T were two shapes with no way to be generic over the difference, so no library could implement a trait for arrays in general.

An array still has two shapes an impl may match — the length written out, and the length as a parameter — and the written-out one is more specific, so it is found first. A block for [2]T still wins over a block for [N]T on arrays of two, which is the same “written-out beats a parameter” ordering override uses.

A member declares its own

A method’s parameter list is its own, exactly as a function’s is — so it may take a value parameter that the type’s parameters know nothing about. The type’s are fixed by the receiver; the member’s are solved at the call:

struct Sum
    seen: usize

    take[const N: usize](*self, xs: [N]int) -> usize
        self.seen = self.seen + N
        N
end Sum

var s = Sum(0)

print(s.take([1, 2, 3]))
print(s.take([4, 5]))
print(s.seen)
3
2
5

Both calls reach one written method and neither passes a length, because N is read off the argument the same way it is for a free function.

A trait declares one too

A trait’s parameter list is a parameter list like a struct’s, so it may take a value. The members read it in their signatures and in their default bodies, and an impl fixes it — the way impl Mul[int] fixes a type:

trait Bytes[const N: usize]
    bytes(self) -> [N]u8
    size(self) -> usize = N

struct W
    x: u8

impl Bytes[4] for W
    bytes(self) -> [4]u8 = [self.x, 0, 0, 0]

val w = W(7)

print(w.size())
print(w.bytes()[0])
print(w.bytes().len)
4
7
4

A bound names it at a value, f[T: Bytes[4]], and so does a trait object, &Bytes[4]. Two values are two implementations, exactly as two type arguments are, so one type may implement Bytes[2] and Bytes[4] side by side. A generic block may abstract over the value too: impl[const M: usize] Bytes[M] for Bits[M].

A default follows the struct rules (§ A parameter may carry a default): a block or an object that leaves the argument out takes it.

trait Hash[const N: usize = 32]
    digest_len(self) -> usize = N

struct Sha
    x: int

impl Hash for Sha

val o: &Hash = Sha(0)

print(o.digest_len())
32

A value is checked against the parameter’s type wherever it is written, the default included:

trait Small[const N: u8]
    n(self) -> u8 = N

struct S
    x: int

impl Small[300] for S
this argument does not fit byte: 300

A parameter may stand for a list of types

A type parameter stands for one type and a const parameter for one value. A parameter written .. stands for a list of types — a pack — which is what lets one declaration cover every tuple:

joined[..A: Display](t: (..A)) -> string
    var s = ""

    for const i in 0..<A.len
        if i > 0 then s = s + "-"

        s = s + str(t.i)

    s

print(joined((1, 2)))
print(joined((1, "two", true, 4.5)))
1-2
1-two-true-4.5

Three things are new there and each does one job.

..A declares the pack, and (..A) is the tuple of it — the only spelling a pack has, wherever it is declared. It matches a tuple of any arity, and the arity is inferred from the argument exactly as an array’s length is: a (int, string, bool) matched against (..A) binds A to those three.

The bound distributes over the members. [..A: Display] says every type in A implements Display, and that is the whole of the bound syntax a pack needs — the ordinary [T: Display] read over a list. It is what makes the membership answerable before any body is compiled, so a tuple holding something unprintable is refused where it is written:

struct Point
    x: int
    y: int

joined[..A: Display](t: (..A)) -> string = str(t.0)

print(joined((1, Point(2, 3))))
Display

for const is unrolled. Its range must be known when the program is compiled — A.len is how many types the pack stands for — and the body is repeated once per value, with i folded in as a compile-time usize. Each copy is type-checked on its own, which is the point: the parts of a tuple have different types, so t.i is a different selection in each copy and one written line covers all of them.

t.i at a compile-time i is the selection t.0 already is, with the position arriving as a constant rather than as a literal. It reaches the parts of a tuple and nothing else — a struct’s fields have names, and a number does not address one.

A for const over a type’s own make-up

The subject may also be a descriptor list: for const f in T::Fields unrolls once per field of a struct, tuple or union, and for const c in T::Variants once per variant of an enum. f and c are compile-time descriptors, v.(f) is that field of a value in each copy, f::Type its type, and c::Fields the payload’s own list, which a nested for const walks. Each copy is checked on its own, as a tuple’s t.i is, so one body serves fields of every type. The descriptors, and what a derivation written with them looks like, are on reflection.

A walk of a type’s own parts unrolls to however many it has, with no limit: the type’s declaration is the bound, a reader can see it, and a derivation over a hundred-variant enum emits what a hundred hand-written arms would. The limit below is for a range.

What a for const will not do

A range computed at run time cannot be unrolled, and the ordinary for is what walks one:

f(n: usize)
    for const i in 0..<n
        print(i)

f(3)
must be known at compile time

A range is unrolled to at most 65 copies (its two ends at most 64 apart). for const i in 0..<100000 is one line and a hundred thousand copies of its body, and nothing on the page says so; a loop that long is a run-time for, which costs one copy whatever it counts to:

f()
    for const i in 0..<100
        print(i)

f()
a 'for const' over 100 values would emit that many copies of its body, and 64 is the most one is unrolled to

break and continue are refused as well. There is no loop at run time for either to act on: the copies are straight-line code inside whatever the for const was written in, so a break would leave that loop — one copy at a time, and silently. A loop written inside the body is an ordinary loop and breaks out of itself as usual.

return does work, and it is what Eq and Ord on a tuple are written with — an unrolled copy is straight-line code in the enclosing function, so a return in one is an ordinary return.

if const — the arm each instance takes

if const cond picks an arm while compiling, and only that arm is compiled. The condition is a constant: a value parameter, a const, a call whose body can run, or the folded arithmetic over them. Each instance of a generic and each copy of a for const decides it again, so one body can hold an arm that makes sense only for some of them:

describe[const N: usize]() -> string
    if const N > 64
        "big"
    elif const N == 0
        "empty"
    else
        "small"

pick[T](x: T) -> int
    if const sizeof(T) == 1
        x.nothing_here()
    else
        7

print(describe[100](), describe[0](), describe[3](), pick(5))
big empty small 7

pick(5) compiles: x.nothing_here() is never type-checked for an int, because the arm it stands in is not the one int takes. An ordinary if would check both arms for every instance.

A chain is decided in one place. Every elif after an if const is an elif const, and a plain else is what is left; a test made while the program runs goes in a plain if inside that else. A condition that is not known while compiling is refused:

main()
    var n = 3
    if const n > 2
        print("x")
an 'if const' is decided while compiling, so its condition has to be known then

A generic body is read once before any instance as well, to check what does not depend on its parameters. There, neither arm of an if const is read; each instance reads the one it takes.

What it is for: the catalog covers every tuple

impl[..A: Display] Display for (..A) is one block covering every tuple there is, and Eq, Ord and Hash are each one more. Before this, a tuple’s arity was part of its shape: a pair and a triple were two shapes with no way to be generic over the difference, so the library wrote a row per arity and stopped at three — and a tuple of four parts implemented nothing.

A tuple now has three shapes an impl may match, and they are found most specific first: the tuple written out in full, then one arity, then every arity.

trait Tag
    tag(self) -> string

impl[..A: Display] Tag for (..A)
    tag(self) -> string = "any"

impl[A: Display, B: Display] Tag for (A, B)
    tag(self) -> string = "pair"

override impl Tag for (int, int)
    tag(self) -> string = "two ints"

print((1, 2).tag(), (1, "x").tag(), (1, 2, 3).tag())
two ints pair any

That is the same “written-out beats a parameter” ordering an array’s two shapes have, one rung longer — and the override is the separate rule it has always been. Coherence says where a block may be written; override says which of two blocks that both have a home answers. A block for a tuple written out in full is the specific one, so it is the one that says so.

A member declares one too, and a trait’s member as well

A pack stands in a method’s own parameter list on the same terms a value parameter does, and for the same reason — the list belongs to the member:

struct Row
    n: usize

    take[..A: Display](*self, t: (..A))
        for const i in 0..<A.len
            self.n = self.n + str(t.i).len
end Row

var r = Row(0)

r.take((1, "abc", true))
print(r.n)
8

A trait’s member takes one too, and not for a reason about packs: a member may declare parameters of its own whatever their kind, and a pack is one more way of writing that list. What it costs is the trait object rather than the declaration — a member with parameters of its own is not a function until a call names them, so no slot of a &Trait‘s table can point at it.

The impl spells the same list — by position, so the letters need not agree — and the types are named at the call, whether that call is on the type or through a bound:

trait Take
    take[..A: Display](self, t: (..A)) -> usize

struct Row
    n: usize

impl Take for Row
    take[..A: Display](self, t: (..A)) -> usize
        var total = self.n

        for const i in 0..<A.len
            total = total + str(t.i).len

        total

through[S: Take](s: S) -> usize = s.take((1, "abc", true))

print(Row(0).take((1, "abc", true)))
print(through(Row(2)))
8
10

What such a member gives up is its table slot, which is a rule about erasure rather than one about packs: no slot can hold a function that does not exist until a call names its types. The trait still has an object and it still dispatches every other member; what is refused is reaching that member through one (traits).

trait Take
    tag(self) -> usize
    take[..A: Display](self, t: (..A)) -> usize

struct P
    n: usize

impl Take for P
    tag(self) -> usize = self.n
    take[..A: Display](self, t: (..A)) -> usize = self.n

val o: &Take = P(5)

print(o.tag())
print(o.take((1, "ab")))
'take' of 'Take' declares type parameters of its own, so it is not a function until a call names them

A struct, an enum and a trait are refused a pack for a different reason — their parameters are their shape, and there is nothing to spread a list over. A member has no shape of its own, so that reason never reached one.

This is not variadic functions

A pack is a compile-time list of types. C’s ellipsis is a run-time walk over untyped storage (ffi), and the two share nothing. There is also no pack expansion: a pack cannot be spread into a call’s arguments, and (..A, int) cannot append to one. The unrolled loop stands in for expansion, in the one direction the catalog needs.

What is deliberately not here

absentwhy, and what to write instead
where clausesthe inline [T: A + B] list is the settled baseline; an out-of-line form is a possible ergonomic addition
type-level arithmetic ([N + 1]T)a value parameter may stand as a length but not be computed with in a type; deciding that N + 1 and 1 + N are one type is a feature of its own
pack expansion (f(..a), (..A, int))a pack may be matched and walked, not spread into an argument list or appended to; for const is what stands in for it
higher-kinded parameters (F[_])excluded, not deferred — it pushes inference toward undecidable, and abstraction over containers is served by traits and bounds

Explicit type arguments at a call are not among them: they are written at the five heads above. They matter most for a nullary generic, which has no argument to be inferred from — buf() and map() are otherwise solved by what receives the result and nothing else, and buf[u8]() says it at the call:

import sysl.buf.buf

var b = buf[u8]()

b.push(3)
print(b.len())
1

Next: modules.

Search

Esc
↑↓ to navigate ↵ to open Esc to close