| title | Generics |
|---|---|
| section | Language |
| order | 8 |
Silt is a statically-typed language with full parametric polymorphism. Type variables are inferred by the compiler using Hindley–Milner inference with let-polymorphism; you rarely need to declare them. When you do, the syntax is a single lowercase identifier — no angle brackets, no special binder keyword on functions.
The guiding principle: generics should be invisible when they can be, and unambiguous when they must be visible. Readers should never have to ask "where did this type variable come from?"
- Lowercase identifiers in type positions are type variables. Uppercase identifiers are concrete types or type constructors.
- Type variables bind at their first appearance in a signature, reading left to right.
- Every type variable must be anchored to a parameter. Either it
appears inside the type of a regular parameter, or it is introduced
explicitly as a
type aparameter (see Return-polymorphic functions). - The compiler does the rest. Polymorphism, specialisation,
constraint resolution, and instantiation at call sites are all
inferred. There is no
fn<T>binder syntax and no turbofish.
fn map(xs: List(a), f: Fn(a) -> b) -> List(b)
-- ↑ ↑ ↑
-- a binds here b binds here both already in scope
Type parameters are declared in parentheses after the type name:
type Option(a) { Some(a), None }
type Result(a, e) { Ok(a), Err(e) }
type Pair(a, b) { first: a, second: b }
type Tree(a) { Leaf, Node(Tree(a), a, Tree(a)) }
Parameters are lowercase. They are in scope throughout the declaration body, including in constructor argument types, record fields, and recursive self-references.
At use sites, types are applied positionally: Option(Int),
Result(String, ParseError), Pair(User, List(Role)).
Constructors are automatically polymorphic:
let a = Some(42) -- Option(Int)
let b = Some("hello") -- Option(String)
let c = Ok(User { ... }) -- Result(User, _)
Write functions without declaring type parameters — the compiler infers them from parameter annotations:
fn identity(x: a) -> a { x }
fn swap(pair: (a, b)) -> (b, a) {
let (x, y) = pair
(y, x)
}
fn compose(f: Fn(b) -> c, g: Fn(a) -> b) -> Fn(a) -> c {
fn(x) { f(g(x)) }
}
Every lowercase name in a type annotation that is not already bound becomes a fresh type variable at the binding point. Subsequent uses of the same name refer to the same variable.
Type variables may appear:
- As a full parameter type:
x: a - Inside a type constructor:
xs: List(a),m: Map(k, v) - Inside a function type:
f: Fn(a) -> b - In the return type, only if already bound by a parameter
Every type variable used in a signature must appear in at least one
input position — either a regular parameter's type, or as a type a
parameter (see below). A type variable that appears only in the return
type or only in a where clause is a compile-time error:
-- ERROR: 'a' only appears in the return type
fn make() -> a { ... }
-- ERROR: 'a' only appears in a where clause
fn parse(s: String) -> Int where a: Parse { ... }
This rule eliminates the "where did a come from?" confusion that
plagues ML-family languages with implicit forall. When the reader
encounters a type variable, its origin is always a parameter they can
point to.
Constrain a type variable to types implementing a trait with where:
fn sort(xs: List(a)) -> List(a) where a: Compare {
...
}
Multiple bounds on one variable separate with +:
fn dedup(xs: List(a)) -> List(a) where a: Equal + Hash { ... }
Bounds on multiple variables separate with ,:
fn merge(a: Map(k, v), b: Map(k, v)) -> Map(k, v)
where k: Hash + Equal, v: Clone
{ ... }
The two forms compose freely — where a: Equal + Hash and
where a: Equal, a: Hash mean the same thing.
where clauses are enforced at every call site. Passing a value whose
element type doesn't satisfy the constraint is a compile-time error
that names both the call and the missing impl.
Traits can be implemented on parameterized types with constraints on the bound parameters. See Traits — Parameterized Implementation Targets for the full specification. A constraint on the impl header applies to every method in the impl and is checked at every call site.
Some functions are genuinely polymorphic in their return type with no
corresponding input — default, empty, parse, decode. For these,
silt uses an explicit type parameter: a function argument whose value
is a type.
fn default(type a) -> a where a: Default {
a.default()
}
fn parse(body: String, type a) -> Result(a, ParseError) where a: Decode {
a.decode(body)
}
fn try_from(x: a, type b) -> b where a: Convertible(b) {
x.convert()
}
Inside the body, the type a parameter doubles as a dispatch target for
trait methods. a.default() invokes the default method of whichever
Default impl matches the concrete type passed in — see
Trait methods on types below.
try_from shows the companion pattern: a parameterized trait
(Convertible(b)) where the trait's own parameter b is bound via the
where clause and flows into the return type.
At the call site, the type is passed like any other argument:
let zero = default(Int)
let todo = json.parse(body, Todo)
let n = try_from("42", Int)
The type passed must be a concrete type (or a type expression in terms of types already in scope). Passing a lowercase type variable only works when that variable is in scope at the call site.
type parameters always appear after regular data parameters, and
are grouped contiguously when there are multiple:
-- Correct
fn parse(body: String, type a) -> Result(a, ParseError)
fn cast(x: a, type b) -> b
fn convert(x: a, type b, type c) -> (b, c)
-- Incorrect — type param before data, won't parse
fn broken(type a, body: String) -> Result(a, ParseError)
The reason is pipe ergonomics. Silt's |> operator inserts the
piped value as the first argument of the right-hand call. If type
parameters came first, every type-directed operation would break out
of pipelines:
let resp = http.get(url)?
resp.body -- extract the response body (a String)
|> json.parse(Todo) -- works: parse(body, Todo)
|> result.map_ok(process)
http.get returns Result(Response, HttpError), and ? unwraps it to
a Response record — not the body string. The pipeline picks up after
resp.body extracts the String field that json.parse actually
wants. The "type params last" convention is what keeps the second and
third arms (json.parse(Todo), result.map_ok(process)) flowing in
pipe form.
The rule is a single convention that keeps silt's pipe-first idiom
working cleanly across the entire type-directed surface
(json.parse, toml.parse, decode, try_from, user-defined
serialisers).
Use type a only when a type variable genuinely cannot be anchored to
a data parameter. Most functions don't need them — if the type can be
inferred from an argument, don't add a type parameter just to make
the choice explicit. The existing value argument is already doing that
work.
| Function shape | Use |
|---|---|
map(xs: List(a), f: Fn(a) -> b) -> List(b) |
No type params; both vars from args |
parse(body: String) -> Result(a, ParseError) |
Error — a unbound |
parse(body: String, type a) -> Result(a, ParseError) |
Correct form |
default(type a) -> a |
a is the only parameter |
Silt offers three ways to pin down a polymorphic expression's type, from most to least preferred:
1. Inference from context (default). The compiler propagates types from surrounding code — variable annotations, argument positions, return types:
fn process(xs: List(Int)) -> Int { ... }
let result = process([1, 2, 3]) -- list type inferred as List(Int)
2. Variable annotation. When a binding needs a specific type, annotate it:
let xs: List(Int) = []
let todo: Todo = json.parse(body, Todo)
3. as ascription. For expressions not bound to a variable, use
as:
let r = (int.parse("42") as Result(Int, ParseError))?
[] as List(Int)
Silt has no turbofish (::<T>), no fn<T> binder syntax, and
no explicit type application operator. If the compiler can't
determine a type, one of the three mechanisms above always suffices.
|> inserts the left-hand value as the first argument of the call on
the right. With the "type params last" rule, type-directed functions
compose naturally:
-- `bytes.to_string` returns `Result(String, BytesError)`, while
-- `json.parse` returns `Result(_, JsonError)`. Wrap each step's error
-- in a shared enum so the chain composes through `?`:
type LoadError {
Decode(BytesError),
Parse(JsonError),
}
fn load_config(raw_bytes) -> Result(Config, LoadError) {
let s = raw_bytes |> bytes.to_string |> result.map_err(Decode)?
json.parse(s, Config) |> result.map_err(Parse)
}
For functions where the piped value should land somewhere other than the first argument, rewrite as a lambda:
-- Instead of trying to pipe into the second slot, use a lambda:
value |> fn(v) { combine(a, v, c) }
Silt deliberately does not provide a placeholder marker (e.g. _) for
pipe target position. Keeping |> to a single rule — "insert as first
argument" — is a core simplicity bet.
A where clause lets a generic function call trait methods:
fn shout(items: List(a)) -> List(String) where a: Display {
items |> list.map { x -> x.display() |> string.to_upper }
}
Traits can be implemented on parameterized types, with optional constraints on the parameters. See the Traits guide for the full specification:
type Box(T) { Box(T) }
trait Display for Box(a) where a: Display {
fn display(self) -> String {
match self {
Box(inner) -> "Box({inner.display()})"
}
}
}
Traits can take type parameters, letting the same trait name represent a family of related interfaces:
trait Convertible(b) {
fn convert(self) -> b
}
trait Convertible(Int) for String {
fn convert(self) -> Int { 0 }
}
-- Adding a second impl on the same source type with a different
-- trait argument (e.g. `trait Convertible(Float) for String`) is
-- a deferred limitation today: the impl key ignores trait params,
-- so the second impl collides with the first as a duplicate. See
-- `docs/proposals/error-from-trait.md` for the full write-up.
A parameterized trait can bound its own parameters with where
clauses. Every impl must supply type args that satisfy the bounds:
trait HashTable(k) where k: Hash + Equal {
fn keys(self) -> List(k)
}
trait HashTable(String) for MyStore { ... } -- OK, String auto-derives Hash + Equal
trait HashTable(Function) for OtherStore { ... } -- error: Function does not implement Hash
Supertraits can also reference the enclosing trait's params. The args
flow automatically — a where x: Child(Int) constraint makes
Parent's methods callable on x with Parent's own params bound to
the same Int:
trait Parent(a) {
fn parent_method(self) -> a
}
trait Child(a): Parent(a) {
fn child_method(self) -> a
}
fn use_parent(x: b, type a) -> a where b: Child(a) {
x.parent_method() -- returns a, bound by Child's arg
}
Convertible(Int) and Convertible(Float) would want to coexist
as distinct impls keyed by their trait argument, but today this is a
deferred limitation: the impl key ignores trait params, so
impl Convertible(Int) for String and impl Convertible(Float) for String collide as duplicates. The same source type (String) cannot
yet implement the trait multiple times with different target
arguments. See docs/proposals/error-from-trait.md for the full
analysis and the workarounds available today.
In a where clause, trait arguments can be concrete types or
lowercase type variables bound elsewhere in the signature:
fn try_from(x: a, type b) -> b where a: Convertible(b) {
x.convert()
}
The compiler substitutes the trait's declared parameters with the supplied arguments when resolving method types at the call site.
Rules:
- Trait declaration parameters must be lowercase type variables
(
trait Foo(a, b)). Binders must be distinct. - An impl must supply exactly one argument per declared parameter; arity mismatch is a compile-time error.
- Parameterless traits (
trait Display { ... }) are the common case and continue to work as before.
Constraints transitively include supertraits. Given a user-defined
Ordered trait whose declaration names Equal as a supertrait
(hypothetical user-defined trait, not built-in), a where a: Ordered
bound makes methods from Equal callable on a as well:
trait Ordered: Equal {
fn less(self, other: Self) -> Bool
}
fn sorted_unique(xs: List(a)) -> List(a) where a: Ordered {
-- Both a.less(b) (from Ordered) and a.equal(b) (from the
-- transitively-included Equal supertrait) are callable here.
...
}
The four built-in auto-derived traits (Equal, Hash, Compare,
Display) are independent — none of them lists another as a
supertrait — so this example uses a user-defined Ordered to show the
supertrait edge being established.
Equal, Hash, Compare, and Display are auto-derived for every
user-defined type. Generic code that constrains on these traits works
against every type by default:
fn dedup(xs: List(a)) -> List(a) where a: Equal + Hash { ... }
-- Works for List(Int), List(User), List(Option(String)), ...
To customise, write an explicit impl — it overrides the derived one.
Some trait methods take no self and only return Self — constructors
like Default::default() or Monoid::empty(). To invoke these without
an instance, call them on a type descriptor: either a bare type
name (Int.default()) or a type a parameter (a.default()):
trait Default {
fn default() -> Self
}
trait Default for Int {
fn default() -> Self { 0 }
}
fn default(type a) -> a where a: Default {
a.default() -- dispatches to Int.default at the call site
}
fn main() {
let n = default(Int) -- 0 — generic path
let m = Int.default() -- 0 — concrete path
}
The rules:
- Dispatch is by the descriptor's carried type name. At runtime the
descriptor
Intresolves to theDefaultimpl forInt;Todoresolves toDefault for Todo; etc. - The descriptor is a dispatch key, not an argument. It doesn't
occupy a
selfslot. Method signatures that declareSelfas a parameter (e.g.fn combine(a: Self, b: Self)) receive only the caller's explicit arguments. whereconstraints are required for the generic path. Writingfn f(type a) -> a { a.default() }withoutwhere a: Defaultrejects at the call toa.default()— the compiler can't prove an impl exists.- Ambiguity across traits is rejected. If both
FooandBardeclare a methodbuildandais constrained toFoo + Bar, callinga.build()errors with "ambiguous method 'build' ontype a: provided by multiple traits (Foo, Bar)".
This is how default, empty, and similar constructor-style trait
methods become directly writable in user code — without silt growing
T::method() path syntax or inherent impls.
A trait can declare a type member alongside its methods. Each impl
binds the member to a concrete type, and the trait's methods may refer
to that member through Self::Item projection. This lets a single
trait relate one self-type to a family of related types without paying
for a trait parameter at every use site.
Declarations live on the trait; bindings live on each impl:
trait Stream {
type Item
fn first(self) -> Self::Item
}
type Wrap { v: Int }
trait Stream for Wrap {
type Item = Int
fn first(self) -> Int {
self.v
}
}
Inside the trait body, Self::Item refers to whatever the impl will
bind. At the call site (w.first() on a Wrap), the compiler reduces
the projection to Int via the impl's type Item = Int binding.
A trait may declare more than one associated type:
trait Pair {
type First
type Second
fn first(self) -> Self::First
fn second(self) -> Self::Second
}
type IntStringPair { a: Int, b: String }
trait Pair for IntStringPair {
type First = Int
type Second = String
fn first(self) -> Int { self.a }
fn second(self) -> String { self.b }
}
A declaration may attach trait bounds to the member. Bindings in impls must satisfy those bounds:
trait Container {
type Item: Compare
fn first(self) -> Self::Item
}
type Box { v: Int }
trait Container for Box {
type Item = Int -- OK: Int auto-derives Compare
fn first(self) -> Int { self.v }
}
Multiple bounds combine with +, the same way method-level constraints
do: type Item: Compare + Hash.
Inside a trait body or impl, Self::Item is the natural form. Outside
a trait — in a free function's signature, for example — write the
fully-qualified <T as Trait>::Item form to project the associated
type of a known concrete impl:
fn first_out(w: Wrap) -> <Wrap as Producer>::Out {
w.produce()
}
The compiler resolves the projection through the registered impl, so
the return type reduces to whatever type Out = ... the impl bound.
A subtrait inherits its supertrait's associated types automatically. You bind them once on the supertrait impl and reference them from either trait body:
trait Super {
type Item
fn one(self) -> Self::Item
}
trait Sub: Super {
fn first(self) -> Self::Item -- Self::Item comes from Super
}
type Wrap { v: Int }
trait Super for Wrap {
type Item = Int
fn one(self) -> Int { self.v }
}
trait Sub for Wrap {
fn first(self) -> Int { self.v + 1 }
}
- Declarations live on the trait.
type Item(optionallytype Item: Compare + Hash) appears inside the trait body alongside method declarations. Order is free — declarations and methods may interleave. - Bindings live on each impl. Every concrete impl must supply
type Item = ConcreteTypefor every associated type the trait declares. Missing bindings are a compile-time error (missing required associated type 'Item'). - No defaults in v1.
type Item = SomeDefaulton a trait declaration is rejected — silt reserves the syntax for a future extension. Bindings are required on every impl. - No duplicates. Two
type Item = ...bindings in the same impl are a compile-time error. Self::Itemis trait-local. Outside any trait body,Selfalone is meaningless; use<T as Trait>::Itemto project from a named type.
fn map(xs: List(a), f: Fn(a) -> b) -> List(b)
fn filter(xs: List(a), f: Fn(a) -> Bool) -> List(a)
fn fold(xs: List(a), init: b, f: Fn(b, a) -> b) -> b
fn group_by(xs: List(a), key: Fn(a) -> k) -> Map(k, List(a))
where k: Hash + Equal
fn map(opt: Option(a), f: Fn(a) -> b) -> Option(b)
fn and_then(opt: Option(a), f: Fn(a) -> Option(b)) -> Option(b)
fn map_ok(r: Result(a, e), f: Fn(a) -> b) -> Result(b, e)
fn map_err(r: Result(a, e), f: Fn(e) -> f) -> Result(a, f)
fn parse(body: String, type a) -> Result(a, ParseError) where a: Decode
fn from_toml(content: String, type a) -> Result(a, ParseError) where a: Decode
-- call sites
let config = from_toml(raw, AppConfig)?
body |> json.parse(Todo)
The stdlib follows the same shape: json.parse(src, T) and toml.parse(src, T)
are the built-in type-directed decoders — hover either name in your editor
for the full per-call documentation surfaced by the LSP.
silt does not ship a built-in Into / TryInto trait. Define your
own conversion trait and implement it per source/target pair:
trait Convert(b) {
fn convert(self) -> b
}
trait Convert(Int) for String {
fn convert(self) -> Int { 0 }
}
fn into(x: a, type b) -> b where a: Convert(b) {
x.convert()
}
The same pattern adapts to fallible conversions by changing the trait
method's return type to Result(b, e) for whatever error type the
caller wants. (Convert(Int) for String and Convert(Float) for String would want to coexist, but see the
"Parameterized trait declarations" subsection below for the current
deferred limitation around trait-arg-keyed impls.)
type Cache(k, v) {
store: Map(k, v),
capacity: Int,
}
fn get(c: Cache(k, v), key: k) -> Option(v) where k: Hash + Equal {
map.get(c.store, key)
}
fn put(c: Cache(k, v), key: k, value: v) -> Cache(k, v)
where k: Hash + Equal
{
c.{ store: map.set(c.store, key, value) }
}
Each exclusion is a design choice, not an oversight. The rationale is preserving silt's minimalism and keeping the mental model small.
Type variables are introduced by first use in a parameter annotation. Declaring them separately at the top of a function would duplicate information the compiler already has and add punctuation silt otherwise avoids.
When the compiler can't infer a type, annotation or as ascription
always works. Turbofish would be a fourth mechanism serving the same
purpose.
No Functor f abstracting over type constructors. HKT pays off
heavily for library authors writing universal abstractions but makes
type errors and inference substantially harder for everyone else.
Silt's position: the stdlib provides the common shapes directly
(List, Option, Result, Map), and that covers the overwhelming
majority of real code.
No Array(n, Int) where n is a value. Fixed-size arrays don't exist
in silt; List is used throughout. If bounded-size containers become
necessary, they'll be added as specific types, not as a generic
feature.
No fn make() -> some Parse or dyn Parse. Patterns that would use
these in other languages are expressed with enums (a closed set of
concrete types) or with concrete wrapper types.
A function cannot require a polymorphic function as an argument
(fn apply(f: (forall a. a -> a), ...)). Hindley–Milner is
rank-1; silt doesn't extend it.
No macro_rules!, no comptime, no quasi-quotation. Type-directed
runtime behaviour (like json.parse) covers the common derive-shaped
use cases without introducing a second language stage.
- Lowercase identifiers in type positions are type variables.
- Uppercase identifiers are concrete types or type constructors.
- Type variables bind at first appearance in a parameter type.
- Every type variable must be anchored — appear in a regular
parameter's type, or be declared as a
type aparameter. typeparameters always come after data parameters, grouped contiguously.- Constraints go in
whereclauses. Multiple bounds on one variable use+; bounds on multiple variables use,. - Call sites never declare type arguments explicitly — the compiler
infers them. Annotation, ascription, or a
typeparameter covers the rare cases when inference can't decide.