Types
Primitive Types
| Type | Description | Examples |
|---|---|---|
Int | 64-bit signed integer (overflow is a runtime error) | 42, -7, 0xFF, 0b1010 |
Float | 64-bit floating-point, guaranteed finite | 3.14, -0.5, 1e5, 2.5e-3 |
Bool | Boolean | true, false |
String | UTF-8 string with interpolation | "hello", "age: {n}" |
Unit | No 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 playgroundRecords 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 playgroundInside 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 playgroundRow 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 playgroundThe 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 playgroundThat 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 playgroundident({ 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 playgroundTrying 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 playgroundThere 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 playgroundcopy(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 playgroundLike 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
| Form | Meaning |
|---|---|
{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.