Jump to content

The Story of GATs: How Rust Finally Fixed Async Traits

From JOHNWICK
Revision as of 08:16, 19 November 2025 by PC (talk | contribs) (Created page with "500px If you’ve ever tried to write an async trait in Rust before 2023, you probably felt pain.
Not “I-forgot-a-semicolon” pain — I mean existential, compiler-induced despair. You’d type something like this: #[async_trait] trait Storage { async fn get(&self, key: &str) -> Option<String>; } …and your IDE would light up like a Christmas tree. You’d google “async trait rust” and end up in the same thread from 2018 w...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

If you’ve ever tried to write an async trait in Rust before 2023, you probably felt pain.
Not “I-forgot-a-semicolon” pain — I mean existential, compiler-induced despair. You’d type something like this:

  1. [async_trait]

trait Storage {

   async fn get(&self, key: &str) -> Option<String>;

} …and your IDE would light up like a Christmas tree. You’d google “async trait rust” and end up in the same thread from 2018 where people were still trying to explain why this is so hard. That’s the story of GATs — Generic Associated Types.
A feature so deceptively small that it took five years, multiple RFC rewrites, and an entire rethinking of Rust’s type system to land in stable.
But now that it’s here, it quietly unlocks async traits, streaming iterators, and self-referential lifetimes — the kind of stuff that used to require unsafe hacks or macro wizardry. Let’s unpack it all: why async traits were impossible, how GATs fix it, and what the compiler’s new trick really looks like under the hood. The Problem: Async Traits Broke Rust’s Model Rust’s async model is state machine–based.
When you write an async fn, the compiler secretly transforms it into a state machine implementing the Future trait. Example: async fn foo() -> i32 {

   42

} Becomes roughly: struct FooFuture { /* hidden state */ }


impl Future for FooFuture {

   type Output = i32;
   fn poll(...) -> Poll<Self::Output> { ... }

} Now, if you put this inside a trait: trait Worker {

   async fn run(&self) -> i32;

} That implies each implementation of Worker will have its own Future type.
But Rust’s trait system (pre-GAT) couldn’t represent that relationship — a trait method returning a type that depends on a lifetime or generic parameter per implementation. You’d get tangled errors like: error[E0562]: `async fn` is not allowed in traits Because the compiler literally couldn’t express “Each Worker impl has its own concrete future type, but callers don’t know what it is.” The Idea Behind GATs Generic Associated Types let you define associated types that are generic over lifetimes or types. Before GATs: trait Stream {

   type Item;
   fn next(&mut self) -> Option<Self::Item>;

} With GATs: trait Stream {

   type Next<'a>;
   fn next<'a>(&'a mut self) -> Self::Next<'a>;

} See the 'a inside the associated type?
That’s the key. It means: “The type returned by next depends on the lifetime of the borrow.” This is exactly what async traits needed — a way to say: “The future returned by this function borrows from self with a specific lifetime.” Async Traits Without Macros (Finally) Let’s rewrite our async trait using GATs. Old Way (using async_trait macro)

  1. [async_trait]

trait Worker {

   async fn run(&self) -> i32;

}

struct Job;

  1. [async_trait]

impl Worker for Job {

   async fn run(&self) -> i32 {
       42
   }

} The async_trait crate works by generating a hidden type-erased future (Pin<Box<dyn Future<Output = T> + Send + '_>>), which adds allocations and dynamic dispatch. New Way (with GATs) use std::future::Future;


trait Worker {

   type RunFuture<'a>: Future<Output = i32> + 'a;
   fn run<'a>(&'a self) -> Self::RunFuture<'a>;

} struct Job; impl Worker for Job {

   type RunFuture<'a> = impl Future<Output = i32> + 'a;
   fn run<'a>(&'a self) -> Self::RunFuture<'a> {
       async move { 42 }
   }

} ✅ No macros
✅ No heap allocations
✅ Fully type-checked at compile time The impl Future syntax works inside the associated type now — because of GATs. Each implementation’s future is different, and that’s fine: the compiler knows it. Architecture: How GATs Fit into Rust’s Type System At a high level, GATs live between two key pieces of the compiler: trait resolution and HRTB (Higher-Rank Trait Bounds). Here’s a conceptual diagram: ┌────────────────────────┐ │ Trait Definition │ │ trait Worker { │ │ type RunFuture<'a>; │ │ fn run<'a>(&'a self); │ │ } │ └──────────┬──────────────┘

          │
          ▼

┌────────────────────────┐ │ Monomorphization Engine│ │ Instantiates per 'a, T │ └──────────┬──────────────┘

          │
          ▼

┌────────────────────────┐ │ Lifetime Solver (HRTB) │ │ Ensures Future<'a> fits │ │ in each impl context │ └────────────────────────┘ The lifetime solver (HRTB) checks that every 'a in RunFuture<'a> is correctly scoped — that’s what makes async borrowing across calls safe. This integration wasn’t trivial.
The compiler team had to rebuild parts of the trait resolution system, refactor the borrow checker, and extend auto trait implementations to understand GATs.
That’s why it took so long — GATs aren’t just syntax; they’re deep type-system plumbing. Real-World Example: Async Database Drivers Before GATs, writing an async database driver that returned streaming rows was a nightmare of pinned boxes and lifetimes. Now, it’s clean and zero-cost. use std::future::Future;


trait DbConn {

   type QueryFuture<'a>: Future<Output = QueryResult> + 'a;
   fn query<'a>(&'a self, q: &'a str) -> Self::QueryFuture<'a>;

}

struct PgConn; impl DbConn for PgConn {

   type QueryFuture<'a> = impl Future<Output = QueryResult> + 'a;
   fn query<'a>(&'a self, q: &'a str) -> Self::QueryFuture<'a> {
       async move {
           QueryResult::new(q)
       }
   }

} No more Pin<Box<dyn Future<...>>>.
The future is stack-allocated, borrow-checked, and optimized inlined code. You can now build zero-allocation async APIs that scale — like mini Tokio runtimes or embedded async systems. Code Flow Diagram User calls async method:

  conn.query("SELECT * FROM users")
       │
       ▼

GAT resolves:

  Self::QueryFuture<'a> → impl Future<Output = QueryResult>
       │
       ▼

Compiler generates:

  Concrete future type tied to &'a self lifetime
       │
       ▼

Executor polls:

  future.poll() → returns QueryResult safely

The whole magic is that 'a — GATs make that lifetime explicit and tracked. The Real Reason This Took So Long The Rust team could have hacked async traits in 2019 (in fact, async-trait did it).
But they didn’t — because the goal wasn’t “make async traits work”, it was “make async traits work safely, efficiently, and in stable Rust.” The tricky part wasn’t async; it was lifetimes that depend on lifetimes.
GATs required redesigning the trait solver (Chalk integration), improving inference, and ensuring backward compatibility with existing trait bounds. In compiler architecture terms: GATs were a type-system foundation that enabled async traits — not a patch. The Emotional Side of It You can feel the community’s relief.
After five years of #[async_trait] macros, lifetime juggling, and nightly experiments, the ecosystem finally stabilized. Libraries like sqlx, reqwest, and tower are now actively migrating to native async traits — removing boxed futures, cutting allocations, and improving compile-time guarantees. It’s one of those rare Rust stories where the pain was worth it.
The language didn’t compromise; it evolved until the type system could express the truth of the code. | Before GATs | After GATs | | ---------------------------- | ---------------------------------- | | Async traits required macros | Native async traits supported | | Boxed futures (heap allocs) | Stack-allocated, type-safe futures | | Complex lifetimes | Borrow-aware signatures | | Poor performance | Compiler-optimized futures | Final Thoughts The arrival of GATs marks a subtle but powerful shift.
They’re not just a feature — they’re a new dimension in Rust’s type system. Now, async traits are no longer magic. They’re pure Rust, borrow-checked, predictable, and fast. The compiler understands what you mean — not just what you wrote.
And that’s the ultimate Rust promise.

Read the full article here: https://medium.com/@theopinionatedev/the-story-of-gats-how-rust-finally-fixed-async-traits-809b7e5206b5