Prompt and scope
You maintain a Service trait used by different executors. Its methods use async fn, but only some call sites pass a Future to a task that may move between threads. Explain why declaring every returned Future as Send is too restrictive, and give selection rules for return type notation (RTN), a trait variant, and a GAT.
The question targets AFIT and RPITIT stabilized in Rust 1.75 and RTN proposed by RFC 3654. RTN is still a nightly experiment; separate language capability, executor requirements, and release stability in your answer.
What the interviewer is testing
A strong answer identifies each Future as an anonymous return type of a trait method. Generic or dyn Trait callers cannot infer its Send property from the trait alone, so the bound belongs at the call site that truly crosses threads.
The interviewer also expects the scope of RTN: it constrains AFIT/RPITIT return values rather than acting as an ordinary field type. Discuss nightly risk, single-threaded executors, work-stealing executors, and compatibility paths.
Clarifying questions
Can the Future move between threads
If the executor pins work to one thread, Send may be unnecessary. A work-stealing executor needs a spawned Future to be Send, and commonly requires 'static as well.
Is the bound for one method or the whole trait
If only call must be movable, RTN can express call(..): Send. If every method needs the same bound, a trait variant avoids repeating method-level clauses.
Can production use nightly
If a public library must support stable Rust, RTN cannot be the only design. Evaluate a generated Send trait or an explicit GAT and record compiler versions in the release matrix.
A 30-second answer
“I first check whether the executor can move tasks and which methods need that guarantee. I keep the base trait minimally constrained, then write T::call(..): Send + 'static at the call site that spawns across threads. If every method needs Send, I use a trait variant; if stable Rust and a named Future are required, I use a GAT. RTN is nightly, so I would test it in an isolated toolchain and keep a stable fallback.”
Step-by-step solution
Step 1: Derive bounds from the executor
A single-threaded or thread-per-core executor usually does not move tasks between threads. A work-stealing executor does, so a Future passed to spawn normally needs Send and often 'static. Send is therefore a consumer requirement, not a default property of every trait method.
Step 2: Constrain one method with RTN
RTN adds bounds to a method's returned type. The following uses nightly syntax:
trait Service<Request> {
type Response;
async fn call(&self, request: Request) -> Self::Response;
}
async fn spawn_call<S, R>(service: S, request: R) -> S::Response
where
S: Service<R> + Send + 'static,
R: Send + 'static,
S::call(..): Send + 'static,
{
tokio::spawn(async move { service.call(request).await })
.await
.expect("task failed")
}S::call(..) refers to the trait method's returned Future, not the value produced after awaiting it. It constrains only consumers that need the property and leaves local implementations usable elsewhere.
Step 3: Compare a trait variant
When every async method needs Send, keep a minimally constrained base trait and generate a Send variant:
#[trait_variant::make(SendService: Send)]
trait LocalService<R> {
async fn call(&self, request: R) -> Response;
}This fits a public API with a common path: implementers target one base trait and callers choose the local or Send variant. The trade-off is weaker method-level precision; RTN is better when only one method needs the bound.
Step 4: Choose a GAT when stable naming matters
If stable Rust must name the returned Future, a GAT can expose an associated type:
trait StableService {
type Future<'a>: Future<Output = Response> + Send + 'a
where
Self: 'a;
fn call(&self) -> Self::Future<'_>;
}Every implementation must provide a concrete Future type, which increases boilerplate. Use this when stable support and storing or reusing the Future type outweigh the ergonomics of async syntax.
Step 5: Validate limitations and migration risk
The Rust team documents RTN as applying to trait associated functions or methods using AFIT/RPITIT; it cannot currently be used as a struct field type. Test syntax, diagnostics, and macro expansion in nightly CI, while preserving a trait-variant or GAT path for stable releases.
Step 6: Turn the comparison into a rule
Choose RTN for a method-level, call-site-level cross-thread requirement; a trait variant when all methods share the requirement; and a GAT when stable Rust and a nameable return type are mandatory. If the executor never moves a task, keep the local Future instead of shrinking the implementation set for perceived safety.
Model high-quality answer
I first ask whether the executor can move a task. With a work-stealing executor such as Tokio, the spawned outer Future generally needs Send + 'static; with a single-threaded executor, propagating that bound to every implementation is unnecessary. I keep the base trait minimal and write S::call(..): Send + 'static only in the generic function that crosses threads. That preserves implementations whose call Future is local-only.
If every async method must be movable, I use a trait variant to offer a Send API. If the project must stay on stable Rust and needs to name or store the Future, I use a GAT. RTN is still nightly, so I isolate it in CI and keep the stable design rather than making experimental syntax the public minimum version.
Common mistakes
- Symptom → Add
Sendto every async return in the trait → Why it fails → Valid single-threaded implementations are excluded → Fix → Put the bound at the cross-thread consumer. - Symptom → Treat
S::call(..)as the awaited result type → Why it fails → RTN bounds the returned Future, notS::Response→ Fix → State the distinction explicitly. - Symptom → Ship nightly RTN directly in production → Why it fails → Compiler and syntax support may change → Fix → Pin nightly in CI and maintain a stable fallback.
- Symptom → Convert every async trait to a GAT → Why it fails → Implementers must expose concrete Future types → Fix → Use GAT only when stable naming is a real requirement.
- Symptom → Check only whether
ServiceisSend→ Why it fails → A movable service can still return a non-Send Future → Fix → Bound the object, arguments, returned Future, and outer task separately.
Follow-up questions and responses
Follow-up 1: Why is S: Send not enough for S::call(..): Send?
S: Send says the service value can move between threads. Its method may still capture Rc or another non-Send value in the Future. A cross-thread spawn checks the outer Future and every awaited Future, so the returned Future needs its own bound.
Follow-up 2: Only put needs Send while get uses a local cache. What do you do?
Keep the base trait and constrain the Backend's put(..): Send at the cross-thread call site. Do not generate a variant that also restricts get, because that would reject a valid local-cache implementation.
Follow-up 3: RTN cannot yet be a field type. How can you store the Future?
Use an explicit GAT, a named Future, or erase it at the boundary with Box::pin. Choose based on stable support, allocation cost, and object-safety needs; RTN does not automatically create a storable type.
Follow-up 4: How do you prove the bounds are not too strong?
Write three compile cases: a local implementation that lacks Send but runs on one thread; an implementation where only put returns a Send Future; and one where every method can be spawned across threads. Run each on stable and nightly and verify failures occur at the intended call site.