December 28, 2023·6 min read·engineeringrustasynctraitsapi-design

Rust 1.75 stabilizes async traits with explicit limits on returned futures

Async functions in traits now work on stable Rust through return-position impl Trait. Public APIs still need a deliberate Send policy, and the new syntax does not yet support native dynamic dispatch.

An async method returns a future whose concrete type callers usually do not need to name. That becomes an API problem when the method belongs to a trait. The trait needs to express the returned behavior without requiring a name for each implementation's generated future. Rust 1.75, released December 28, makes that possible on stable Rust through return-position impl Trait in traits and native async fn declarations.

The Async Working Group's December 21 explanation also describes the limits. The new syntax supports important static-dispatch uses, but public APIs still need to decide which bounds their returned futures promise. Native dynamic dispatch for these traits is not part of this stabilization.

I find Rust's semantics impressive because the language makes many implementation obligations visible. Async traits are useful for the same reason. They let an interface describe asynchronous behavior directly, while the remaining restrictions expose decisions an API author cannot safely leave implicit.

Returning an implementation no longer requires naming its type

Rust has supported return-position impl Trait in free functions since Rust 1.26, according to the working group's account. A function can promise an iterator without exposing the exact iterator type assembled by its implementation. This is useful for types that are cumbersome or impossible to name explicitly, including closures.

Rust 1.75 extends the notation to trait definitions and their implementations. A method can return some type implementing an interface while allowing each implementation to choose its concrete type. A simple private trait can express an iterator-producing operation directly.

trait Values {
    fn values(&self) -> impl Iterator<Item = u32>;
}

The promise in this declaration concerns Iterator and its item type. It does not promise every additional capability that a particular implementation's iterator might happen to have. That distinction between the declared contract and an implementation detail becomes central when callers want to impose extra requirements.

Async methods use the same facility because an async fn returns a value implementing Future. The output type written after the async method is the future's eventual output. The generated future itself remains the method's returned value, with a concrete type determined by its implementation.

trait Reader {
    async fn read(&self) -> Vec<u8>;
}

This example states the type relationship only. A real reader can choose a fallible output appropriate to its protocol. Stabilizing the declaration does not prescribe an error policy or make asynchronous I/O infallible.

Generic callers cannot add every missing return bound

The working group's iterator example shows the limitation clearly. A trait promises an iterator, but a generic caller wants to reverse it. Reversal requires DoubleEndedIterator. Even if some implementations return a type supporting that operation, the generic caller cannot assume it from the trait's narrower promise.

In this initial release, callers cannot add the missing bound to the opaque return type through a general facility. The trait author must anticipate the required bound or choose another representation, such as an associated type when the type can be named. A useful implementation capability is inaccessible to generic code if the API has no way to express it.

This affects public APIs differently from private traits. Within a codebase, an author can revise a private trait when a new use requires a stronger contract. A published trait has downstream implementations and callers. Strengthening its requirements can become an API compatibility change instead of a local edit.

The working group therefore discourages general public use of return-position impl Trait unless the author can anticipate the bounds users need. That recommendation does not say the feature is unstable. It says the stable feature still has limits that influence which public contracts it can express well.

Send describes a future as well as the service

For async methods, Send is a particularly important additional bound. A multithreaded executor may need to move a future between threads. A generic caller then needs a guarantee about the returned future, not merely about the type on which the method was called.

The native async spelling in a public trait does not let the author state that returned-future auto-trait bound directly. The working group's explanation shows a warning for bare public async methods and suggests spelling the return type explicitly when a Send promise is required.

use std::future::Future;

pub trait Reader: Send {
    fn read(&self) -> impl Future<Output = Vec<u8>> + Send;
}

The service's Send bound and the returned future's Send bound are separate parts of this declaration. The latter is what permits a caller to rely on moving the in-progress operation between threads under the relevant executor's requirements. A service type being movable does not by itself state every property of a future produced by one of its methods.

An implementation can still use the async fn spelling if its generated future satisfies the declared requirements. The working group explicitly allows mixing the async spelling and explicit future-returning spelling between traits and implementations. The required bounds remain the contract that the implementation must meet.

I prefer making this choice while designing the trait instead of leaving downstream users to discover it through a task-spawning error. A future that is valid on a local executor can be unsuitable for a caller that needs thread mobility. Both are useful configurations, and the interface should say which it supports.

Trait variants provide two public choices

The working group recommends the trait-variant crate for authors who want to provide local and Send variants. Its procedural macro can generate a second trait with the additional bounds from an async trait declaration. The crate is published by the Rust organization, but using it remains a dependency choice separate from the language syntax.

This gives callers a way to select a contract that fits their executor without asking one trait to imply conflicting requirements. A local variant does not impose Send on every future. The generated stronger variant makes that requirement available to generic consumers that need it.

The explanation also records a limitation for generic middleware. A type cannot always implement the Send variant conditionally on one of its generic parameters in the desired pattern. Generating variants helps with an important API case without solving every relationship among generic implementations and returned futures.

That is useful context for evaluating whether to adopt the new native syntax in a library. The question is whether the expressible variants match the intended consumers. A source-level simplification can be valuable even when another part of the API still needs an existing technique.

Dynamic dispatch remains outside native support

Traits using these opaque returns or async methods are not object-safe in this release. They cannot directly supply the corresponding native trait-object dispatch. The working group's announcement lists dynamic dispatch as one reason a project may continue to need the async-trait macro.

Supporting compiler versions older than Rust 1.75 is another reason to retain that macro. The stable language feature changes what the new compiler accepts. It does not make older compilers understand the syntax, so a library's minimum supported Rust version remains part of the decision.

Static generic use and dynamic trait-object use should therefore remain separate in descriptions of the release. Calling both async trait support without qualification can hide the most important difference for an application that stores heterogeneous implementations behind trait objects.

The compiler also warns when an implementation refines an opaque-return contract with more specific information. The working group explains that such information can expose more implementation detail than intended. A concrete return may make additional operations available to callers who know the implementing type. The warning asks the implementer to acknowledge that refinement instead of silently treating the narrower trait declaration as the whole externally visible contract.

Lifetime capture follows the trait method's inputs

The working group also explains why examples of these methods do not need the familiar explicit + '_ bound often seen on other opaque returns. In traits, the returned type is already assumed to capture input lifetimes. A returned future can therefore depend on borrowed input according to those rules without repeating that spelling.

This concerns how the hidden type relates to its inputs. It does not permit a borrowed value to outlive its referent. The feature makes the method expressible while retaining the lifetime relationships required by the operation it represents.

My judgment is that Rust 1.75 makes a useful class of asynchronous interfaces direct enough to write in ordinary trait syntax. The remaining public-API work is also concrete. Authors must choose the returned future's mobility requirements and decide whether their callers need dynamic dispatch or an older compiler. The new declaration can then communicate a contract that its implementations and generic callers both understand.