· 6 min read ·

Rust's Trait Solver at the Breaking Point: HKT Emulation and Inductive Cycles

Source: lobsters

Higher-kinded types (HKTs) are one of Rust’s most-discussed missing features. The absence is not an oversight; it is a deliberate trade-off rooted in Rust’s design philosophy around zero-cost abstractions and compile-time safety. That trade-off has a real cost, though: abstracting over type constructors, a routine operation in Haskell or Scala, requires elaborate workarounds in Rust. This recent exploration shows what happens when those workarounds are pushed far enough to trigger an inductive cycle in the compiler’s trait solver, and in the most extreme cases, crash the compiler outright.

What Higher-Kinded Types Actually Are

A concrete type like i32 or String has kind *. A type constructor like Vec or Option has kind * -> *: given a concrete type, it produces another concrete type. Higher-kinded types let you abstract over those constructors, writing a Functor trait that works for any F: * -> * rather than only for Option or Vec specifically.

In Haskell this is first-class:

class Functor f where
  fmap :: (a -> b) -> f a -> f b

Here f ranges over type constructors of kind * -> *. Rust has no direct equivalent. You can write impl<T> Functor for Vec<T>, but you cannot abstract over Vec itself as a type parameter. That gap has accumulated a long list of workarounds, each progressively more stressed than the last.

The GAT Approach

Generic Associated Types, stabilized in Rust 1.65 in October 2022, brought the language considerably closer to HKT territory. A GAT is an associated type that takes type parameters of its own, which allows you to encode a type constructor as a trait:

trait HKT {
    type Applied<T>;
}

struct OptionF;
impl HKT for OptionF {
    type Applied<T> = Option<T>;
}

struct VecF;
impl HKT for VecF {
    type Applied<T> = Vec<T>;
}

With this scaffold, a Functor trait becomes expressible:

trait Functor: HKT {
    fn map<A, B>(
        fa: Self::Applied<A>,
        f: impl Fn(A) -> B,
    ) -> Self::Applied<B>;
}

This compiles and works for the straightforward cases. The compiler has to reason about <F as HKT>::Applied<T> across trait bounds, and that reasoning is delegated to the trait solver, a component that predates GATs and was not designed with HKT emulation as a target use case.

The Lifetime Trick That Came Before

Before GATs were available, the common pattern used higher-rank trait bounds over lifetimes to simulate type constructor application. The idea is to encode a type constructor as a trait parameterized by a lifetime, then use that lifetime as a proxy for the type argument:

trait WithLifetime<'a> {
    type Applied;
}

struct OptionF;
impl<'a, T: 'a> WithLifetime<'a> for (OptionF, std::marker::PhantomData<T>) {
    type Applied = Option<T>;
}

To express “for all types T, F applied to T” you write F: for<'a> WithLifetime<'a>. It works after a fashion, but the encoding is fragile. Error messages when something goes wrong are spectacular in their inscrutability, and the indirection through lifetimes creates its own class of borrow checker confusion.

Inductive Cycles in the Trait Solver

Rust’s trait solver works by constructing a proof tree. When it needs to prove T: Trait, it searches for applicable impl blocks, unfolds them, and recursively proves any where clauses those impls require. This is essentially Prolog-style resolution.

Circular dependencies are the central hazard:

trait A: B {}
trait B: A {}

Proving T: A requires T: B, which requires T: A, which requires T: B, and so on. In Prolog, this is often handled coinductively: if you can assume the goal and derive it from that assumption, you accept it as proven. That is unsound for Rust’s type system. Rust’s solver therefore uses inductive reasoning for most contexts, meaning circular proof obligations fail rather than being accepted.

The failure mode you see in practice is either an overflow evaluating the requirement error (when the recursion depth exceeds the configured #[recursion_limit], defaulting to 128) or, in more pathological cases, an Internal Compiler Error (ICE), which is a panic inside rustc itself.

HKT emulation creates cycles that are harder to detect than the simple mutual-recursion example above. Consider bounds of this form:

where
    F: HKT,
    F::Applied<A>: Clone,
    <F as HKT>::Applied<<F as HKT>::Applied<A>>: Debug,

The solver must normalize Applied at each level while simultaneously checking bounds on the result. If any of those bounds have blanket implementations that re-invoke the HKT trait indirectly, the solver can enter a cycle where each step looks syntactically distinct even though the underlying proof obligation repeats. The solver may not identify it as a cycle until it has exhausted its recursion budget.

Raising #[recursion_limit] does not fix this. It delays the overflow and makes the compile-time cost worse, but the cycle is still there.

Where the Compiler Gets “Borked”

An ICE in rustc is distinct from a compile error. A compile error is a deliberate, handled outcome; an ICE means the compiler itself panicked, usually with a message like internal compiler error: unexpected panic followed by a request to file a bug report.

Complex GAT-based HKT emulation is a reliable way to trigger ICEs in older stable Rust releases. The solver state machine has edge cases around recursive associated type normalization that produce unreachable code paths when the nesting depth exceeds what the original implementation anticipated. These are genuine bugs in rustc, not user errors, and many of them have been filed and fixed over the past few releases. The right response to a new one is to reduce the reproduction to a minimal case and open an issue on the Rust repository.

The New Trait Solver

The Rust compiler team has been developing a next-generation trait solver as a replacement for the current implementation. The new solver is more principled about cycle handling. It maintains an explicit cache of proof obligations in progress, distinguishes coinductive from inductive contexts as a first-class concept, and short-circuits cycles with defined semantics rather than hoping the recursion limit catches them first.

As of early 2026, the new solver is enabled by default for coherence checking, which is where many of the most critical correctness properties are enforced. General trait solving remains opt-in via the -Znext-solver unstable flag. Enabling it will make some previously-ICE-triggering programs produce a proper error instead, and will make some previously-accepted programs with unsound cycle proofs fail to compile.

Chalk, an earlier experimental solver modeled as a set of Horn clauses with well-defined proof semantics, informed much of this work. The a-mir-formality project continues in that direction with a formal model of the full type system. The long-term goal is a solver where the behavior on any given program is derivable from a specification, not from inspecting the implementation.

A Language Design Perspective

Rust’s designers have been clear that full HKTs are not off the table, but the implementation complexity is substantial. The issue is not syntax; it is that full HKTs require the solver to handle impredicative polymorphism, where type variables can be instantiated with types that themselves contain quantifiers. This interacts with lifetime inference in ways that are hard to make predictable.

Haskell can afford first-class kind polymorphism because its type inference is based on Hindley-Milner, extended to handle * -> * kinds in the original design. Scala achieves something similar through explicit higher-kinded type parameters (F[_]) with kinds tracked in a dedicated pass separate from type inference. Rust’s inference is already considerably more complex because of lifetimes and the borrow checker. Layering kind inference on top would almost certainly make type errors harder to read and diagnose, which is a cost the language has historically been unwilling to pay.

GATs are the current stepping stone. They cover enough of the practical use cases that library authors can write useful abstractions, and the higher crate on crates.io demonstrates that Functor, Applicative, and even Monad are expressible for common container types. But the solver limits are real, and the cases that trigger ICEs or inductive cycles are not exotic: they arise from ordinary attempts to compose these abstractions.

The work on the new trait solver is the most direct path toward making HKT emulation robust. Until it stabilizes and the cycle-handling semantics are locked in, any production use of GAT-based HKT patterns should be tested against the latest nightly to catch bugs that have been fixed but not yet backported to stable.

Was this interesting?