Type Classes
Type classes provide ad-hoc polymorphism in Monad, similar to Haskell type classes and Rust traits. They allow you to define interfaces that types can implement.
Basic Syntax
Define a type class with the class keyword:
class Container (F : Type -> Type) {
def wrap : A -> F A
def size (fa : F A) : I64
}
Default Implementations
Class methods may provide a default body with :=:
class Describe A {
def describe (a : A) : String := "<generic>"
def shout (a : A) : String
}
Two limitations to be aware of today:
- An instance must still list every method it wants, including ones it is happy
to take the default of. An empty instance body
{ }is a parse error, so there is no way to write "use all the defaults". The prelude'sMonadStateis the first shipped class with real defaults, andexamples/state_monad.moduly spells out all five of its methods,modifyandget_mapincluded. - Instance methods need full type annotations.
def describe x := "int"does not parse; writedef describe (x : I64) : String := "int".
class Describe A {
def describe (a : A) : String := "<generic>"
}
instance Describe I64 {
def describe (x : I64) : String := "int"
}
Classes with Constraints
Type classes can require other classes as constraints:
class [Functor F] Applicative (F : Type -> Type) {
def pure : A -> F A
def apply : F (A -> B) -> F A -> F B
}
class [Applicative M] Monad (M : Type -> Type) {
def bind (a : M A) (f : A -> M B) : M B
def pure : A -> M A
}
The [Functor F] syntax means "F must have a Functor instance".
Multiple Parameters
Classes can have multiple type parameters:
class Convert A B {
def convert : A -> B
}
Default Type Parameters
A class parameter can have a default, which is used when the class is named without one:
class FromListLiteral (L : Type -> Type := List) {
def cons (a : A) (L A) : L A
def empty : L A
}
Type Class Constraints on Functions
Functions can require instances using bracket syntax:
def process [Functor F] {A B : Type} (f : A -> B) (fa : F A) : F B :=
Functor.map f fa
Infix Operators from Classes
You can bind an infix operator to any function, including a class method:
infix (>>=) := Monad.bind
infix (+) := HAdd.add
infix (*) := HMul.mul
The Standard Classes
These are the classes that actually ship. Note where each one lives — only the prelude ones are available without an import.
In the prelude (no import needed)
| Class | Methods | Notes |
|---|---|---|
Functor (F : Type -> Type) | map | |
Applicative (F) | pure, apply | requires Functor |
Monad (M) | bind, pure | requires Applicative |
IndexedMonad (M) | pure, bind, map, and_then, lift | indexed by two phantom parameters |
MonadState (M) | get, set, modify_get, modify, get_map | * has a default body. The state type is an implicit forall, not a class parameter |
MonadLift m n | monad_lift | lift a computation from m into n |
MonadLiftT m n | monad_lift_t | transitive form; the reflexive MonadLiftT m m instance does not dispatch (see below) |
IndexedMonadState (M) | get, set, modify_get | indexed counterpart of MonadState |
IndexedMonadLift m n | monad_lift | indexed counterpart of MonadLift |
FromListLiteral (L := List) | cons, empty | drives [a, b, c] |
HAdd A B C / Add A | add | + binds HAdd.add |
HMul A B C | mul | * |
Sub A | sub | - |
Div A | div | / |
Append A | append | ++ |
BEq A | beq | == |
BOrd A | lt, gt | <, > |
ToString A | to_string | |
Hashable A | hash |
Add and HAdd are wired to each other in both directions: an HAdd A A A
instance gives you Add A, and vice versa.
Elsewhere in the standard library
| Class | Module | Methods |
|---|---|---|
From T A | init | from |
Semigroup A | init.foldable | combine |
Monoid A | init.foldable | mempty |
Foldable (T) | init.foldable | foldr, foldl |
Traversable (T) | init.foldable | traverse |
Ord A | std.base | compare (three-way, returns Ordering) |
Semigroup A | std.base | combine |
Monoid A | std.base | empty |
Default A | std.base | default |
Enum A | std.base | succ, pred, to_nat, from_nat |
Bounded A | std.base | min_bound, max_bound |
Show A | std.show | show |
Debug A | std.debug | debug |
Map (M := HashMap) | std.map | empty, insert, lookup, delete |
Known wart.
SemigroupandMonoidare declared twice — once ininit/foldable.moand once instd/base.mo— with different method names (memptyvsempty). They are unrelated classes that happen to share a name. Import only one of them in a given file.
Show and Debug are deliberately different: Debug is a Rust-style
diagnostic representation (it quotes strings), Show is a display string.
ToString, in the prelude, is what the numeric types implement.
There is no Mul class (only HMul), and DefaultValue in the prelude is a
type, not a class — the class you want is Default in std.base.
Instance Resolution Is Not Fully Checked
Instance resolution happens during evaluation, not during check. A program
that uses a class method with no matching instance will type-check cleanly and
then fail at run time:
eval error: unresolved global: Monad.bind
This is a real gap, not a subtlety of the design — see the
Maturity Matrix. If you are relying on an instance, run the code
(or a #[test]), do not just check it.
There is a second, quieter version of the same problem. Resolution keys on the
head of the instance's type, so an instance whose head is a type variable
can never be matched. The prelude ships two — instance MonadLiftT m m and the
instance {I : Type} [IndexedMonad M] Monad (M I I) bridge — and both are, in
practice, declarations of intent. Write the concrete instance out instead; see
Instances.
Summary
- Type classes define interfaces for types
- Constraints
[C A]require instances, on both classes and functions - Instance methods need full annotations, and instance bodies cannot be empty
- A missing instance is currently a run-time error, not a check-time one
Next, we'll learn about instances and how to implement type classes.