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's MonadState is the first shipped class with real defaults, and examples/state_monad.mo duly spells out all five of its methods, modify and get_map included.
  • Instance methods need full type annotations. def describe x := "int" does not parse; write def 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)

ClassMethodsNotes
Functor (F : Type -> Type)map
Applicative (F)pure, applyrequires Functor
Monad (M)bind, purerequires Applicative
IndexedMonad (M)pure, bind, map, and_then, liftindexed 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 nmonad_liftlift a computation from m into n
MonadLiftT m nmonad_lift_ttransitive form; the reflexive MonadLiftT m m instance does not dispatch (see below)
IndexedMonadState (M)get, set, modify_getindexed counterpart of MonadState
IndexedMonadLift m nmonad_liftindexed counterpart of MonadLift
FromListLiteral (L := List)cons, emptydrives [a, b, c]
HAdd A B C / Add Aadd+ binds HAdd.add
HMul A B Cmul*
Sub Asub-
Div Adiv/
Append Aappend++
BEq Abeq==
BOrd Alt, gt<, >
ToString Ato_string
Hashable Ahash

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

ClassModuleMethods
From T Ainitfrom
Semigroup Ainit.foldablecombine
Monoid Ainit.foldablemempty
Foldable (T)init.foldablefoldr, foldl
Traversable (T)init.foldabletraverse
Ord Astd.basecompare (three-way, returns Ordering)
Semigroup Astd.basecombine
Monoid Astd.baseempty
Default Astd.basedefault
Enum Astd.basesucc, pred, to_nat, from_nat
Bounded Astd.basemin_bound, max_bound
Show Astd.showshow
Debug Astd.debugdebug
Map (M := HashMap)std.mapempty, insert, lookup, delete

Known wart. Semigroup and Monoid are declared twice — once in init/foldable.mo and once in std/base.mo — with different method names (mempty vs empty). 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.