Generics
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?”
The mental model
- 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
Generic type declarations
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, _)
Generic functions
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 {
{ 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.
A type variable may not be spelled like a known type in lower case:
fn f(x: int) is an error: “unknown type ‘int’ — did you mean Int?”.
(Type names always start with a capital letter, so such a name is a typo.
One-letter names such as a, e or t are always type variables.)
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
The binding rule
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.
A type variable is any type
Inside the function a type variable stands for a type the function does
not know: the body must work for every type the caller may choose. It
cannot treat an a as an Int, return a b where it promised an a,
or call a method no where clause promises:
fn one(x: a) -> a { 1 } -- ERROR: expected a, got Int
fn second(x: a, y: b) -> a { y } -- ERROR: expected a, got b
fn show(x: a) -> String { x.display() } -- ERROR: `a` has no trait bound
fn show(x: a) -> String where a: Display { x.display() } -- ok
An annotation in the body that writes one of the signature’s variables
means the same variable (let first: a = x). A variable that a let
annotation introduces itself is any type for that let:
let id: Fn(b) -> b = { y -> y } is general in b, and
let n: b = 1 is an error.
Functions that call each other may leave their return types out and still annotate parameters with variables: inside the group two such variables of two functions can be the same one, and each function stays general in it.
A function without annotations is not restricted this way: its type is
inferred from its body, bounds included, so fn wrap(x) { show(x) } has
the type Fn(a) -> String where a: Display. Functions that call each
other are inferred together, and inside such a group each has one type;
annotate a function to call it at two types from within its own group.
Where clauses
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: Display
{ ... }
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.
Constraints on impl targets
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.
Return-polymorphic functions
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()
}
type DecodeError {
Malformed(String),
}
fn parse(body: String, type a) -> Result(a, DecodeError) 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.
Why type parameters come last
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, DecodeError) { ... }
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, DecodeError) { ... }
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 text = io.read_file(path)?
let done = text
|> json.parse(Todo) -- works: parse(text, Todo)
|> result.map_ok(process)
io.read_file returns Result(String, IoError), and ? unwraps it to
the String that json.parse 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).
When to use type parameters
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 |
Inference, annotation, and ascription
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, int.IntError))?
let none = [] 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.
Pipe interaction
|> 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(bytes.BytesError),
Parse(json.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 |> { 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.
Trait interactions
Generic functions using traits
A where clause lets a generic function call trait methods:
import list
import string
fn shout(items: List(a)) -> List(String) where a: Display {
items |> list.map { x -> x.display() |> string.to_upper }
}
Generic trait implementations
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()})"
}
}
}
Parameterized trait declarations
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 has Hash + Equal
trait HashTable(Fn(Int) -> Int) for OtherStore { ... }
-- error: type 'Fn' does not implement trait '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.
Supertrait expansion
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.
...
}
Among the built-in traits, Compare includes Equal in the same way
(a where a: Compare bound lets the body use ==); the example uses a
user-defined Ordered to show the supertrait edge being written.
The four structural traits
Equal, Hash, Compare, and Display are traits the language
answers itself, by the structure of a type: a type has one of them when
every type it is made of has it. Generic code that constrains on these
traits works against every such type:
fn dedup(xs: List(a)) -> List(a) where a: Equal + Hash { ... }
-- Works for List(Int), List(User), List(Option(String)), ...
A type that holds a function has none of them (a channel has Equal
only, a map or a set no Compare): the error is at the use that needs
the trait. Display is the one a program may write an impl of
(trait Display for T); an impl of Equal, Hash or Compare is an
error. See Built-in Traits.
Trait methods on types
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() {
println(default(Int)) -- 0
println(Int.default()) -- 0
}open in playgroundThe 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.- A method without
selfis called on a type, never on a value.x.default()on a valuex: ais an error that names the call to write:a.default(), with atype aparameter. - Ambiguity across traits is rejected. If both
FooandBardeclare a methodbuildandais constrained toFoo + Bar, callinga.build()errors with “ambiguous method ‘build’ ontype a: provided by traits Bar, Foo”.
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.
Associated types
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
}
}
Bounds on associated types
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 has Compare
fn first(self) -> Int {
self.v
}
}
Multiple bounds combine with +, the same way where bounds
do: type Item: Compare + Hash.
Qualified projection: <T as Trait>::Item
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.
Supertrait inheritance
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
}
}
Rules
- 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.
Worked examples
Collection operations
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 { ... }
Option and Result combinators
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) { ... }
Type-directed decoding
fn parse(body: String, type a) -> Result(a, DecodeError) where a: Decode { ... }
fn from_toml(content: String, type a) -> Result(a, DecodeError) where a: Decode { ... }
-- call sites
let config = from_toml(raw, AppConfig)?
let todo = 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.
Conversion
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 above for the current
deferred limitation around trait-arg-keyed impls.)
User-defined generic container
import map
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) }
}
What silt deliberately does not have
Each exclusion is a design choice, not an oversight. The rationale is preserving silt’s minimalism and keeping the mental model small.
No explicit generic binders (fn<T>)
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.
No turbofish or explicit type application
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 higher-kinded types
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 constants in type parameters
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 existential return types or trait objects
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.
No higher-rank polymorphism
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 user-definable macros or compile-time code
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.
Summary of rules
- 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. Inside the function it stands for any type: the body cannot decide it, and may use only what thewhereclauses promise of it. 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.