Invisible State Machines: Understanding Rust’s impl Future Return Types

Discover how compiler-generated futures work behind the scenes—and why they’re both powerful and perplexing.

Invisible State Machines: Understanding Rust’s impl Future Return Types
An opinionated commentary on the peculiarities of returning a future from a function in Rust, focusing especially on the “opaque” nature of impl Future<Output = T> and how that leads to complexities with traits, boxing, and compiler-generated state machines.

“What Type Is This, Anyway?”

Have you stared at a signature like this:

fn do_something() -> impl Future<Output = u32> { ... }

…and wondered,

“Wait—what future type is being returned here?”

We see impl Future<Output = u32>, but that doesn’t name a concrete type. It just says, “This function returns some Type that implements Future—which, when awaited, yields a u32.”

This raises a question:

"What is this type, exactly?
"If the compiler knows the specific type, why is it hidden? And if it’s so important, why can’t we write it down?"

That sense of type mystery is both a powerful feature and a sometimes‐confusing quirk of Rust’s async system. Let’s unpack why.


The “Hidden” State Machine Type

When you write an async fn, Rust compiles it into a unique state machine. Internally, each async fn is lowered into something resembling:

fn do_something() -> <StateMachineStruct> {
    // ...
}

…but that <StateMachineStruct> doesn’t appear in your source code. It’s a compiler-generated type that implements Future<Output = u32>. Each time you create a new async function, the compiler derives a new struct with fields to keep track of the function’s local variables and the “suspended states” between each await.

Now you might ask—why can’t we see this type, or name it in our code?

Why is it Opaque?

Since the compiler auto-generates it (think of it like a custom enum or a generator), it isn’t publicly named or stabilized for your code to reference. You only get to see impl Future<Output = u32>. That’s an opaque type: it exists, but you can’t name it directly.

Paradoxical or Powerful?

  • Advantage: The Rust compiler can create specialized, optimized state-machine futures without cluttering your source code.
  • Downside: It's a complicated construct to wrap your head around!

The Compiler Always Knows the Concrete Type

It’s important to note that, behind the scenes, the type is concrete. The Rust compiler:

  1. Generates a unique type for each async function.
  2. Knows exactly how big it is, what fields it holds, how to poll it, etc.

In other words, it’s only opaque to us. Rust uses impl Trait to shield us from that messy internal detail. That can be beneficial (it makes code simpler to read and handles “infinite generics” elegantly), but it also leads to occasional friction.


The Trait-Method Conundrum

One area where “just returning impl Future<Output = T>” runs into trouble is trait methods. For instance:

trait Service {
    fn call(&self) -> impl Future<Output = u32>;
}

This signature is not allowed in stable Rust (without special techniques) because each implementer might produce a different unique future type, and traits typically want a single consistent return type for all implementers.

The usual workaround? BoxFuture<Output = u32>:

trait Service {
    fn call(&self) -> BoxFuture<'static, u32>;
}

You store the unique state machine behind a pointer, unifying the “type” to a single trait object. This is simpler for trait definitions but means you pay for heap allocation (boxing) and sometimes an extra dynamic dispatch cost. That might or might not be a big deal for your use case, but it directly results from future types being “private” to each function.


Why Bother with This Complexity?

You might ask: “Why not let me just define my future type up front?”

State Machine Efficiency

One major upside: Performance. The Rust compiler can generate a specialized struct that covers all your local variables across all await points in an optimal layout. No generic overhead or dynamic dispatch is needed. Instead, you get a zero-cost abstraction for asynchronous code. The flip side is that this results in unique, un-nameable types.

Some Pain, Some Gain

  • Pain: Harder to read impl Future<Output = T> in function signatures, confusion about where the type is.
  • Gain: No runtime overhead, fully inlined state machines, no extra manual type definitions.

How to Think About It

A good mental model is: every async function you write is secretly generating a brand-new struct behind the scenes—the “type that implements Future<Output = T>.” However, that type is not spelled out for you; you’re just told,

“I give you some future and you can .await it to get eventually T"

So when you see:

async fn foo() -> u32 {
    10
}

The function is essentially:

  1. Capturing local state in a hidden struct.
  2. Returning impl Future<Output = u32>.
  3. The compiler ensures that if you .await the result, you get a u32.

But…Where Is That Type?

It’s in the compiler’s codegen, not your source. Consider how closures generate anonymous types—except async functions generate more complicated “generator” types that track partial states across suspension points.


Conclusion: Balancing Power and Complexity

Rust’s approach to async—“opaque” but concrete future types—gives you:

  • Powerful zero-cost abstractions: Each async function compiles to efficient, specialized code.
  • Simplicity at call sites: You just say “impl Future<Output = _>” in your function return or .await the result.
  • Tricky corners: Especially around trait methods, you must resort to BoxFuture or other patterns. Plus, it can be mind‐bending to accept a “type you can’t name.”

Is it worth it? Usually, yes. The performance gains and flexibility overshadow the mental overhead—once you wrap your head around the notion that “the compiler generates a hidden type for each async function.” But it’s a conceptual leap for newcomers.


Takeaways

Next time you see -> impl Future<Output = u32>, remember:

  • It’s returning an unknown (to you) state machine type that implements Future.
  • The compiler is fully aware of that type—just not you.
  • If you need a trait method to return a future, consider BoxFuture or crates like async-trait.
  • Embrace the weirdness, and enjoy the performance and composability that Rust’s async design offers!

In short,

  • Opaque because the compiler generates them on the fly.
  • Concrete from the compiler’s perspective.
  • Can’t use in traits directly → must box or use other patterns.

Constructs like these increasingly makes me feel,

“You don’t pay for what you don’t use”—but sometimes you pay with a bit of confusion!