Trust is earned, not given

A different perspective

2025-05-13 · Projects

Rust, part 13: Rust 1.85, edition 2024, and async closures

Part 13from the Rust series · 15 parts in all

February 2025 brought two things at once: Rust 1.85 and the 2024 edition. The edition is the more interesting half, because it is the mechanism that lets the language make breaking changes without breaking anyone — and this time it also carried a feature people had waited a very long time for.

An edition is an opt-in breaking change

# Cargo.toml
[package]
edition = "2024"     # was "2021"; per-crate, so a workspace can migrate one crate at a time

Editions change semantics, not syntax availability: crates of different editions link into one binary, so migration is per-crate and can be done incrementally. cargo fix --edition performs the mechanical parts, and the honest way to read the result is that it got your code compiling on the new edition — the behaviour changes still need your attention.

What 2024 changes is mostly about making previously-accepted-but-wrong code stop compiling: the lifetime capture rules for impl Trait now capture all in-scope lifetimes (which fixes real bugs and breaks code that relied on the old narrow rule), unsafe extern and unsafe attributes must be written unsafe explicitly, the gen keyword is reserved for generators, and the temporary scope of a tail expression in a block changed — the one most likely to alter what your code does rather than what it says.

Async closures, at last

// Before: a closure that returns a future cannot borrow its arguments,
// because it has no way to say "the future borrows from what I captured".
let handler = |xc| async move { save(xc).await };

// 1.85: an async closure, which is a closure whose call returns a future
// borrowing from the closure - the missing piece, and it is FnMut.
let handler = async |xc: Item| -> Result<()> { save(xc).await };

items.for_each(handler);   // no `async move` dance, no boxing, no macro

This is the small-looking feature that removes a whole genre of workaround. Retry helpers, middleware, and anything taking "an async callback" used to require a boxed Fn() -> BoxFuture from the futures crate, an allocation per call, and a lifetime bound most people copied without understanding. With AsyncFn / AsyncFnMut / AsyncFnOnce traits alongside, it is plain code.

The combination is what makes 1.85 a step change rather than an incremental release: async closures to pass around asynchrony, plus part 8's async fn in traits, means the async subset of Rust finally composes without a macro layer holding it together. Next: the edition's other headline syntax and the run of releases that followed.