Esc
Start typing to search...

Traits

A trait describes behaviour that many types can share. It is Keel's mechanism for ad-hoc polymorphism: one name (show, compare, …) that resolves to a different implementation depending on the type it is used with. Traits replace the old catch-all comparable type variable with something precise and extensible.

Why traits exist

Before traits, a function that needed to compare or order its argument relied on a magic comparable type variable. That worked for the built-in primitives but you could never extend it: there was no way to say "my type can be ordered too", and the compiler could not tell you when a type genuinely lacked an ordering. Traits fix both problems — ordering and equality are now ordinary, nameable capabilities, and the compiler reports a clear error when a required implementation is missing.

Declaring a trait

A trait declaration names the capability, a type parameter, and the method signatures that any implementation must provide:

trait Show a
    show : a -> String

This says: a type a implements Show when it provides a show function of type a -> String.

Implementing a trait

trait … for … gives an implementation of a trait for a concrete type. Each method is written as an ordinary fn (the signature is taken from the trait definition, so none is repeated here):

trait Show a
    show : a -> String

trait Show for Int
    fn show x = "an int"

trait Show for Bool
    fn show x = "a bool"

(show 42, show True)
Try it

show 42 dispatches to the Show for Int implementation and show True to the Show for Bool one — the compiler picks the right one from the argument's type.

Only one implementation of a trait may exist per type. A second trait Show for Int block is a compile error (DuplicateTraitImpl), and an implementation of a trait that was never declared is a TraitNotFound error.

Constrained functions

A function can require that its type parameter implement a trait by writing a constraint before its signature with =>:

trait Show a
    show : a -> String

trait Show for Int
    fn show x = "n"

fn describe : Show a => a -> String
fn describe x = show x

describe 7
Try it

fn describe : Show a => a -> String reads "for any a that implements Show, describe takes an a and returns a String." Inside the body, show x resolves through the constraint. Calling describe with a type that has no Show implementation is a MissingTraitImpl error, reported at the call site.

Multiple constraints are written in parentheses:

fn pair : (Show a, Show b) => a -> b -> String

Superconstraints

A trait can require another trait with needs. Ord (ordering) needs Eq (equality), because anything you can order you can also compare for equality:

trait Ord a needs Eq a
    compare : a -> a -> Int

When you implement Ord for a type, the compiler also checks that Eq is implemented for it — a missing superconstraint surfaces as MissingTraitImpl.

Built-in traits: Eq, Ord, and Hash

Keel ships built-in Eq and Ord implementations for every primitive type: Int, Float, Decimal, String, Bool, and Char. This is what makes == and ordering work out of the box, and it is what List.sort relies on:

import List

[3, 1, 2] |> List.sort
Try it

List.sort has the signature Ord a => [a] -> [a], so sorting a list of a type without an Ord implementation is a compile error rather than a silent runtime surprise. List.minimum, List.maximum, and Math.min / max / clamp are constrained by Ord in the same way; List.unique is constrained by Eq.

Because Ord needs Eq, a value with an Ord implementation can always be compared with == as well.

Hash

Hash a requires Eq a and provides:

hash : Hash a => a -> Int

All primitive types (Int, Float, Decimal, String, Bool, Char, Symbol) have built-in Hash implementations when deriving (Hash) appears anywhere in the program. The function uses FNV-1a (64-bit) and returns a signed Int. Equal values always produce the same hash:

enum Color = Red | Green | Blue deriving (Hash)

hash Color::Red   -- some deterministic Int
hash Color::Red == hash Color::Red  -- True
hash Color::Red == hash Color::Blue -- False (in practice)

Hash is the foundation for future dictionary and set types where the key type must be hashable.

Built-in numeric traits

Keel's numeric type hierarchy is expressed as a trait tower. Each level is a superconstraint of the one below:

Numeric a          — Int, Float, Decimal  (+, -, *, /)
  └── Integral a   — Int only             (div, mod, …)
  └── Fractional a — Float, Decimal       (/, decimal division)
        └── Real a — Float, Decimal       (transcendental functions)

The Real constraint is required by the six transcendental Math functions:

FunctionSignature
Math.sqrtReal a => a -> Maybe a
Math.powReal a => a -> a -> Maybe a
Math.expReal a => a -> Maybe a
Math.logReal a => a -> Maybe a
Math.log10Real a => a -> Maybe a
Math.log2Real a => a -> Maybe a

These accept both Float and Decimal arguments. Passing Int, String, or Bool is a compile-time MissingTraitImpl error.

Real also provides two conversion methods:

  • Real.toRational : a -> Decimal — converts a Float or Decimal to Decimal. For Float inputs the conversion is exact for values representable within Decimal's 28-digit precision; larger or non-representable values round.
  • Real.fromInt : Int -> a — produces a Float or Decimal from an integer literal. The return type must be determinable from context; a bare Real.fromInt 5 without an annotation is an AmbiguousType error.
import Math
import Maybe

-- Float dispatch
Math.sqrt 4.0  -- Just 2.0 : Maybe Float

-- Decimal dispatch (full decimal arithmetic, no float round-trip)
Math.sqrt 4.0d -- Just 2.0d : Maybe Decimal

-- fromInt with annotation
let x : Decimal = Real.fromInt 5   -- 5.0d

deriving — automatic implementations

For common structural traits, Keel can generate an implementation automatically. Write deriving (TraitName) at the end of an enum definition:

enum Direction = North | South | East | West deriving (Eq, Ord, Show, Hash)

Supported derivable traits:

TraitEffect
Eqeq tests constructor identity (and field equality for tuple variants)
Ordcompare orders constructors by declaration order, then fields
Showshow renders to a string like "North" or "Pair 1 2"
Hashhash computes a deterministic integer via FNV-1a and polynomial field mixing

Deriving Ord automatically includes Eq. Deriving Hash automatically includes Eq. Attempting to derive an unknown trait name is a compile error (DerivingUnknownTrait).

Row constraints: Lacks

A row constraint is a condition on the open row variable of a DataFrame type, separate from the trait system. The only row constraint today is Lacks.

r Lacks "col" asserts that the row variable r does not already contain a column named col. It is written in the same constraint prefix as trait constraints:

fn addTimestamp : r Lacks "ts" => DataFrame { | r } -> DataFrame { ts: DateTime | r }
fn addTimestamp df = ...

When the function is called, the compiler checks that the concrete row bound to r does not include ts. If it does, a LacksConstraintViolated compile error is emitted:

-- ERROR: r Lacks "ts" violated — input already has column "ts"
addTimestamp alreadyHasTsDf

Multiple Lacks constraints use the parenthesised form:

fn addAuditCols
    : (r Lacks "created_at", r Lacks "updated_at")
    => DataFrame { | r }
    -> DataFrame { created_at: DateTime, updated_at: DateTime | r }
fn addAuditCols df = ...

Lacks and trait constraints may appear together in the same prefix:

fn process : (Show a, r Lacks "result") => a -> DataFrame { | r } -> DataFrame { result: String | r }

When the open row variable r is not yet bound (the DataFrame is still abstract), the constraint is deferred — no error fires until a concrete row is substituted. When r is bound to RowTail::Unknown (a DataFrame whose schema is not statically known, such as the result of fromRecords), the constraint is vacuously satisfied: keel's gradual typing accepts unknown schemas where concrete ones would be checked.

Lacks constraints enable safe composition of schema-extending pipelines:

fn withId : r Lacks "id" => DataFrame { | r } -> DataFrame { id: Int | r }
fn withTimestamp : r Lacks "ts" => DataFrame { | r } -> DataFrame { ts: DateTime | r }

-- OK: withId adds "id", then withTimestamp adds "ts" — no overlap
let result = df |> withId |> withTimestamp

Without Lacks, calling withTimestamp on a DataFrame that already has a ts column would silently produce a broken schema. With Lacks, it is a compile error.

What traits do not support yet

Keel's traits are deliberately a small, well-understood core. The following are not supported today:

  • Associated types — a trait has one type parameter and method signatures only.
  • Multi-parameter traits — a trait constrains exactly one type variable.

These are natural future extensions, but leaving them out keeps dispatch simple and every trait error precise.