Skip to content

Type classes

joshsh edited this page Jul 17, 2026 · 1 revision

Type classes

Hydra has a lightweight type-class system. A type class is a named constraint attached to a type variable, propagated through inference. The class does not carry runtime evidence: there is no dictionary passing. Instead, each host recovers the required behavior by dispatching structurally on the runtime value.

This page introduces the class system, explains how it interfaces with inference, and shows how constrained primitives and constrained user code are mapped into target languages that have no native type-class mechanism of their own.

For the surrounding type system, see Concepts § Type system and inference. For how constraints are discovered and propagated, see Inference § Class constraints.

The built-in classes

Hydra ships three built-in type classes, defined as TypeClass-valued bindings in the hydra.classes term module. The TypeClass record (in hydra.typing) is a thin wrapper around a human-readable description; the binding's local name is the marker stored on constrained type variables.

Class Meaning
equality Instances support structural equality.
numeric Instances support arithmetic (addition, subtraction, multiplication, negation).
ordering Instances support a total ordering (and equality).

A constraint on a type variable is stored as a set of bare class names — for example "numeric" — in TypeVariableConstraints.classes. So a primitive like add carries the scheme numeric a => a -> a -> a, and equality.compare carries ordering a => a -> a -> comparison.

User-defined type classes are not currently supported. The three classes above are the closed, built-in set. Extending the system to user-declared classes and instances is a possible future direction, but nothing in the kernel or the hosts depends on it today.

How classes interface with inference

Constraints are usually discovered from usage rather than annotated by hand. Inference accumulates a constraint on a type variable when it meets an operation that requires one:

  • using a type variable as a Map key or Set element generates an ordering constraint;
  • applying add/sub/mul/negate generates a numeric constraint;
  • when type variables are unified, their constraints transfer to the resulting type.

For example, topologicalSort :: [Pair t t] -> TopSortResult t infers ordering t because its body uses Map t internally, and addAll := Lists.foldl Math.add ... infers numeric a => [a] -> a because it composes add.

Constraints are tracked in the InferenceContext, propagated through substitution, and generalized into a primitive's or definition's type scheme. At use sites, where a constrained variable is instantiated to a concrete type, the constraint is discharged.

This is a lightweight implementation, not the full dictionary-passing translation found in Haskell. Enforcement is partial: a constrained variable resolved to a type with no corresponding instance is not currently rejected, so add "a" "b" is not caught by a class check (it is usually caught earlier by ordinary unification). Closing that enforcement gap is shared work across all three classes, tracked separately.

Mapping to targets without native type classes

The Haskell host can lean on Haskell's own Eq/Ord/Num when emitting code. The other hosts — Java, Python, Scala, TypeScript, and the Lisp dialects — have no type-class mechanism, yet they must still run constrained primitives and constrained user code correctly.

They do so with one uniform pattern, and the crucial point is that no dictionary is passed:

  1. The constraint lives only in the type scheme, for inference. The primitive is registered with an identity (pass-through) coder, so at runtime it receives the raw value rather than a decoded native one.
  2. The runtime dispatches structurally on the value's literal variant (IntegerValue.{Int8..Bigint}, FloatValue.{Float32,Float64}) and computes accordingly.

The reason a dictionary is unnecessary is that Hydra's numeric types are a closed set and the runtime value always carries enough type information to recover the operation. This is the same mechanism the hosts already use for equality.compare, which every host implements by inspecting the runtime value, never by consulting a class.

Two call contracts

A constrained primitive is reached two ways, and a complete host implementation handles both:

  • The interpreter contract — when a term graph is evaluated, the primitive's implementation receives raw terms and dispatches on the literal variant.
  • The generated-code contract — when Hydra generates host code that calls the primitive, the emitted call passes native values (for example, generated Java calls Add.apply(index, 1) and generated Python calls math.add(1, n)).

The interpreter contract is the same on every host. The generated-code contract is where the hosts differ, because their native type systems differ.

Java

Java's arithmetic is statically typed, so a constrained primitive must satisfy the compiler at every generated call site while still working for any numeric type.

  • Interpreter path: NumericDispatch matches the operand's IntegerValue/FloatValue variant, computes in BigInteger (integers) or double/float (floats), and re-wraps the same variant. Fixed-width integers wrap two's-complement; bigint keeps full precision.
  • Generated-code path: the static entry point is generic and erased — <A> A apply(A, A) — mirroring Compare.apply. A definition polymorphic over numeric (such as a generated <A> A addAll(List<A>)) can then reference Add::apply as a function value and compile, because the type variable never has to resolve a monomorphic overload. The body reads the operands as java.lang.Number and selects the arithmetic with an instanceof ladder — Number has no +, so the ladder is what recovers the operation.

No Numeric<T> dictionary type is introduced. Runtime type dispatch on erased generics — the pattern already used for ordering/equality — is sufficient for a closed set of numeric types.

Python

Python is dynamically typed, so its arithmetic operators are already runtime-polymorphic, which makes the generated-code contract trivial: a generated math.add(x, y) is just x + y and works for any numeric x, so no generic-apply machinery is needed.

Each arithmetic primitive is written in one dual-mode function that serves both contracts, exactly as equality.compare does:

def add(x, y):
    try:
        return x + y                                  # generated-code path: native values
    except TypeError:
        return _numeric_binary(x, y, lambda a, b: a + b)  # interpreter path: raw terms

On the interpreter path the term dispatch inspects the IntegerValue/FloatValue variant and re-wraps the same variant. Because Python integers are arbitrary-precision, fixed-width results are explicitly narrowed to their variant's width (two's-complement for signed, modular for unsigned) so that overflow matches the Haskell and Java hosts; bigint keeps full precision.

The other hosts

Scala, TypeScript, and the Lisp dialects follow the same shape: a class constraint on the type scheme plus structural runtime dispatch, with equality.compare as the model already present in each. Their numeric support lands as those hosts are ported.

See also

  • Concepts § Type system and inference — the type system this sits within.
  • Inference § Class constraints — constraint discovery and propagation.
  • Coding style — conventions for kernel and DSL source.

Clone this wiki locally