-
Notifications
You must be signed in to change notification settings - Fork 25
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.
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.
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
Mapkey orSetelement generates anorderingconstraint; - applying
add/sub/mul/negategenerates anumericconstraint; - 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.
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:
- 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.
-
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.
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 callsmath.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'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:
NumericDispatchmatches the operand'sIntegerValue/FloatValuevariant, computes inBigInteger(integers) ordouble/float(floats), and re-wraps the same variant. Fixed-width integers wrap two's-complement;bigintkeeps full precision. -
Generated-code path: the static entry point is generic and erased —
<A> A apply(A, A)— mirroringCompare.apply. A definition polymorphic overnumeric(such as a generated<A> A addAll(List<A>)) can then referenceAdd::applyas a function value and compile, because the type variable never has to resolve a monomorphic overload. The body reads the operands asjava.lang.Numberand selects the arithmetic with aninstanceofladder —Numberhas 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 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 termsOn 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.
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.
- 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.