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.