Types

Primitive Types

TypeDescriptionExamples
Int64-bit signed integer (overflow is a runtime error)42, -7, 0xFF, 0b1010
Float64-bit floating-point, guaranteed finite3.14, -0.5, 1e5, 2.5e-3
BoolBooleantrue, false
StringUTF-8 string with interpolation"hello", "age: {n}"
UnitNo meaningful value(returned by println)

No implicit conversions. Use int.to_float() or float.to_int() explicitly.

Numeric Literals

All numeric literals support _ as a visual separator: 1_000_000, 0xFF_FF.

-- Decimal
let n = 42
let big = 1_000_000

-- Hex and binary (always Int)
let mask = 0xFF
let flags = 0b1010_0001

-- Scientific notation (always Float)
let avogadro = 6.022e23
let tiny = 1e-9
let hundred = 1e2 -- Float(100.0), not Int

Scientific notation always produces a Float, even when the value is a whole number. A Float is always printed with a decimal point and without an exponent, so a whole one ends in .0: println(1e2) prints 100.0. Non-finite results like 1e999 are rejected at compile time.

Numeric Safety

silt treats silent wrong answers as worse than crashes. The numeric types are designed so that every value in Int and Float is a finite, ordinary number.

Integer overflow

Int is 64-bit signed. Arithmetic that would overflow is a runtime error, not silent wrapping:

println(9223372036854775807 + 1) -- runtime error: integer overflow
let min = -9223372036854775808 -- the smallest Int
println(int.abs(min)) -- runtime error: integer overflow: abs(-9223372036854775808)

The smallest Int is written -9223372036854775808: the minus sign and the digits are one literal there. Without the minus sign, 9223372036854775808 is one more than the largest Int and is rejected (number literal too large).

Finite floats

A Float is always finite: never NaN, never infinite. An operation whose result would be NaN or infinite is a runtime error that names the operation, matching the integer rule:

println(1.0 / 2.0) -- 0.5
println(1.0 / 0.0) -- runtime error: float division by zero
println(float.max_value * 2.0) -- runtime error: float overflow
println(math.sqrt(-4.0)) -- runtime error: math.sqrt of a negative number: -4.0

The same holds for every function that returns a Float: math.log of a number that is not positive, math.asin / math.acos outside -1..1, math.pow and math.exp overflow, and list.sum_float / list.product_float overflow all raise. float.parse returns an Err for "inf", "NaN" and out-of-range literals, and decoding a non-finite number into a Float field (toml.parse) is an error.

Where an input can be out of range, guard it explicitly:

let ratio = match total {
  0.0 -> 0.0
  _ -> part / total
}

Because every Float is finite, floats compare, sort and hash as ordinary numbers and work as map keys and set elements. -0.0 is the same value as 0.0: 0.0 * -1.0 prints 0.0.

No implicit coercion

There are no implicit conversions between Int and Float. Convert explicitly with int.to_float(n) and float.to_int(x).

Enums (Tagged Unions)

type Shape {
  Circle(Float),
  Rect(Float, Float),
}

type Color {
  Red,
  Green,
  Blue,
}

Constructors create values: Circle(5.0), Rect(3.0, 4.0). The compiler checks exhaustiveness when you match on them.

Generic Types

type Option(a) {
  Some(a),
  None,
}

type Result(a, e) {
  Ok(a),
  Err(e),
}

Type parameters are filled in at use: Option(Int), Result(String, String).

Records

type User {
  name: String,
  age: Int,
  active: Bool,
}

fn main() {
  let alice = User { name: "Alice", age: 30, active: true }
  println(alice.name) -- Alice
}
open in playground

Records declared in another module can be constructed with a module-qualified path — no selective import needed. The qualifier is explicit naming, not a second construction form: users.User { ... } builds exactly the same value as User { ... } does after import users.{ User }, and it picks the right declaration when two imported modules export the same type name.

-- noexec (multi-file: users.silt declares `pub type User { ... }`)
import users

let alice = users.User { name: "Alice", age: 30, active: true }

Record update syntax creates a new record with fields changed:

let alice2 = alice.{ age: 31 }

Read as “alice, but with age 31.” Compare to Elm { u | age = 31 }, Rust User { age: 31, ..u }. Silt’s .{ } syntax avoids new keywords or sigils.

Anonymous Records and Row Polymorphism

Beside nominal records (type User { ... }), silt has anonymous structural records: a record literal or type written without a name. Two anonymous records with the same fields and field types are the same type — there is no type Foo { ... } declaration to anchor identity.

let alice = { name: "Alice", age: 30 }
let bob = { name: "Bob", age: 25 } -- same type as alice
println(alice.name) -- Alice

An anonymous record is shown as it is written, without a name, its fields in the order of their names: println(alice) prints {age: 30, name: Alice}. A closed one has ==, .equal(), .hash() and .display() when its fields do, and no order (<, .compare()).

The type of alice is the structural type {name: String, age: Int}, which can also appear in any annotation:

fn full_name(p: { first: String, last: String }) -> String {
  "{p.first} {p.last}"
}

Open rows: ...r

A record type can leave its tail open with ...r, where r is a lowercase row variable — a polymorphic placeholder for “any further fields you happen to have.” The function only commits to the fields it names; the row variable absorbs whatever else the caller passes:

fn first_name(p: { name: String, ...r }) -> String {
  p.name
}

fn main() {
  println(first_name({ name: "Alice", age: 30 }))
  println(first_name({ name: "Bob" }))
}
open in playground

Inside the function p.name is the only legal access; p.age would be rejected, even when the caller passes a record that happens to have an age field. The row variable of an annotation stands for whatever the caller’s record has, and the function may assume nothing about it.

A row variable can be threaded into the return type so the caller’s extra fields survive the round trip:

fn id_name(p: { name: String, ...r }) -> { name: String, ...r } {
  p
}

fn main() {
  let q = id_name({ name: "Alice", age: 30 })
  -- `age` came along with the row
  println(q.age) -- 30
}
open in playground

Row variables are inferred at first appearance, just like ordinary type variables: a function written without annotations like fn show_name(p) { p.name } is inferred to take an open record carrying at least a name field.

Nominal records flow into open rows

Nominal records widen to open rows automatically, so a fn taking a {name: String, ...r} parameter accepts any nominal record that has a name: String field:

type Person {
  name: String,
  age: Int,
}

fn name(p: { name: String, ...r }) -> String {
  p.name
}

fn main() {
  println(name(Person { name: "Bob", age: 42 })) -- Bob
}
open in playground

The row is then the record itself: what a function gives back through the row is still a Person, with its methods and patterns.

type Person {
  name: String,
  age: Int,
}

fn id_name(p: { name: String, ...r }) -> { name: String, ...r } {
  p
}

fn main() {
  let q = id_name(Person { name: "Bob", age: 42 }) -- q is a Person
  println(q.age) -- 42
}
open in playground

That is the only place a nominal record and an anonymous record type meet. An anonymous record is not a Person, and a Person is not a closed anonymous record: converting is written out.

fn greet(p: Person) -> String { p.name }
greet({name: "x", age: 3})
-- error: type mismatch: expected Person, got {name: String, age: Int}
-- help: an anonymous record is not a `Person`: write `Person { ... }`

let r: {name: String, age: Int} = Person { name: "x", age: 3 }
-- error: type mismatch: expected {name: String, age: Int}, got Person
-- help: a `Person` is not an anonymous record: convert it with a spread, `{...v}`, or take an open row, `{x: T, ...r}`

(A type of another module is written with its module in the help: time.Date { ... }.)

A value whose fields are read is a record, and p.greet() on it calls a function held in a field greet. A method of a declared record is not a field: a function that reads p.name and calls p.greet() needs its parameter annotated, p: Person, to call the method.

A row variable stands for the rest of a record’s fields, not for a type, so the only bounds it takes are the traits a record has when its fields do, Display, Equal and Hash, for a function that prints, compares for equality or hashes the whole record:

fn show(p: { name: String, ...r }) -> String where r: Display {
  "{p}"
}

Any other bound on a row variable (where r: Compare, a trait of the program) is an error where it is written.

Closed rows reject extra fields

A record type without ...r is closed: the caller must supply exactly the listed fields, no more, no less:

fn ident(x: { a: Int, b: String }) -> { a: Int, b: String } {
  x
}

fn main() {
  println(ident({ a: 1, b: "hi" })) -- {a: 1, b: hi}
}
open in playground

ident({ a: 1, b: "hi", c: 9 }) is an error (“record literal has unexpected field not declared in target type: c”), and so is ident({ a: 1 }) (“record literal is missing required field: b”).

Record extension: {...p, ...}

The same ... token, in a record expression, spreads an existing record into a new one. Additional fields after the spread are appended; attempting to overwrite a field that already exists is rejected:

fn main() {
  let p = { name: "Alice" }
  let q = { ...p, age: 30 } -- {name: String, age: Int}
  println(q.name) -- Alice
  println(q.age) -- 30
}
open in playground

Trying to redefine an existing field — {...p, name: "Bob"} — is a compile-time error. The shape is “extend, never overwrite”; use record update (p.{ name: "Bob" }) for that. The same holds behind an open row: fn ext(p) { {...p, age: 30} } takes any record that has no age, and a call with one that has is an error at the call:

ext({ age: 1 })
-- error: cannot extend the record with field 'age': it has one already
-- help: a record is extended, never overwritten: update the field with `r.{ age: ... }`

A spread always makes a new anonymous record: its value never carries the name of a declared type. Over a value of a declared record type it is the conversion, written out: {...person} is the anonymous record with Person’s fields, and a field written after the spread is added, or replaces the record’s by name. The type of the base may be decided anywhere in the function, before the spread or after it:

type Person {
  name: String,
  age: Int,
}

fn main() {
  let p = Person { name: "Bob", age: 42 }
  let r = { ...p, city: "x" } -- {name: String, age: Int, city: String}
  println(r.city) -- x
}
open in playground

There is no spelling for the other direction: a Person is built from its fields, Person { name: r.name, age: r.age }.

A function that spreads a record whose type it does not know makes an anonymous record over the record’s row, so it takes anonymous records only: a declared record does not stand for a row that a function spreads, with or without fields after the spread. Convert first, copy({...person}):

type Person {
  name: String,
  age: Int,
}

fn copy(p) {
  { ...p }
}

fn ext(p) {
  { ...p, city: "x" }
}

fn main() {
  println(copy({ name: "Bob" })) -- {name: Bob}
  println(ext({ ...Person { name: "Bob", age: 42 } })) -- {age: 42, city: x, name: Bob}
}
open in playground

copy(Person { name: "Bob", age: 42 }) is an error: “cannot spread a Person through ‘copy’: convert it where its type is known, {...p}”.

The same holds for a closure bound with let, for a function whose signature writes the row (fn tag(p: {name: String, ...r}) -> ... whose body spreads p), and through a function that passes its parameter on to one of these. The row variable of a trait method’s signature, or of a let annotation, cannot be spread at all.

Pattern destructuring with rest

Record patterns mirror the type form. A {name: nm, ...rest} pattern binds the listed fields, and rest to an anonymous record of the fields the pattern does not name, the same way ..rest works on lists. It does so in match, let, when let and a parameter alike, and whatever the record is: the rest of a declared record is an anonymous record too, never the declared type.

type Person {
  name: String,
  age: Int,
}

fn main() {
  let p = { name: "A", age: 30 }
  match p {
    { name: nm } -> println(nm) -- A
  }
  let { name, ...rest } = Person { name: "B", age: 42 } -- rest is {age: Int}
  println(rest.age) -- 42
}
open in playground

Like a spread, a rest binder over a record whose type the function does not know makes an anonymous record over its row: a function that binds one takes anonymous records only.

See pattern matching for the full record-pattern grammar.

Summary

FormMeaning
{name: "A", age: 30}Anonymous record literal
{name: String, age: Int}Closed structural record type — exactly these fields
{name: String, ...r}Open row — at least name, plus any tail captured by r
{...p, age: 30}Record extension — copy p, append age (no overwrite)

Tuples

Fixed-size, heterogeneous:

let pair = (1, "hello")
let (x, y) = pair

Parentheses around one type or one expression only group: (Int) is Int and (1) is 1. A tuple of one element is written with a comma, (Int,) and (1,); () is the unit type and its value.

Recursive Types

Types can reference themselves:

type Expr {
  Num(Int),
  Add(Expr, Expr),
}

Function Type Annotations

let apply: Fn(Int, Int) -> Int = { a, b -> a + b }

type Handler {
  name: String,
  run: Fn(String) -> String,
}

Type Ascription

When type inference cannot determine a type from context, use as to assert it:

let x = [] as List(Int)
let r = (int.parse("42") as Result(Int, int.IntError))?

as is a compile-time assertion — if the types conflict, you get a type error. At runtime it’s a no-op.