← Back to index
PR #3953Work-in-progress preview from an open pull request.View on GitHub ↗
REVIEW
#3953

try_blocks_heterogeneous

AuthorMusicalNinjaDad
CreatedApr 12 2026
UpdatedJun 25 2026
Rust Issue

Summary

RFC 3721 implemented default support for homogeneous try {...} blocks, where all ?s return the same error type. This RFC aims to provide support for explicit annotation of the returned error type from a try {...} block.

Motivation

I'm a bit concerned about this change. Applications and libraries often use crates like thiserror to automatically group errors. For example, I often write something like

#[derive(Error)]
enum MyError {
    #[error("Failed to parse config: {0}")]
    InvalidConfig(#[from] serde::Error),
    #[error("Failed to connect to server: {0}")]
    ServerConnectionFailed(#[from] io::Error),
    ...
}

which I then use as

fn example() -> Result<(), MyError> {
    let config = parse_config()?; // ? promotes serde::Error to MyError
    let server = connect_to_server(server.url)?; // ? promotes io::Error to MyError
    // ...
}

With this change, this approach would stop working in try blocks.

~ purplesyringa commenting on #3721

Currently there is no way to get the following example to compile, as the compiler is unable to safely determine the correct types returned from the try blocks, and no notation is available for the user to specify the type:

#![feature(try_blocks)]

use std::num::ParseIntError;

#[derive(Debug)]
struct Error1;

#[derive(Debug)]
struct Error2;

impl From<ParseIntError> for Error1 {
    fn from(_: ParseIntError) -> Self {
        Self
    }
}

impl From<ParseIntError> for Error2 {
    fn from(_: ParseIntError) -> Self {
        Self
    }
}

impl From<Error1> for Error2 {
    fn from(_: Error1) -> Self {
        Self
    }
}

impl From<Error2> for Error1 {
    fn from(_: Error2) -> Self {
        Self
    }
}

fn err1(s: &str) -> Result<i32, Error1> {
    Ok(s.parse()?)
}

fn err2(s: &str) -> Result<i32, Error2> {
    Ok(s.parse()?)
}

fn heterogeneous_into_exists() {
    let x = try { err1("1")? + err2("2")? };
    let y = try { err2("1")? + err1("2")? };
    assert_eq!(x.unwrap(), y.unwrap());
}

The initial experimental approach to provide a proof-of-concept introduced the (deliberate placeholder) syntax try bikeshed ... {...} in PR #149489.

For the remainder of this RFC we will continue with bikeshed to allow for examples which work on current nightly with #![feature(try_blocks_heterogeneous)].

See open questions and try bikeshed: What should the syntax be? for consideration of possible target syntax.

This would allow the above example to become:

fn heterogeneous_into_exists() {
    let x = try bikeshed Result<_, Error1> { err1("1")? + err2("2")? };
    let y = try bikeshed Result<_, Error1> { err2("1")? + err1("2")? };
    assert_eq!(x.unwrap(), y.unwrap());
}

and for cases where no direct Into relationship exists, or is needed, via a common third error type:

use std::{error::Error, fmt::Display};
impl Error for Error1 {}
impl Display for Error1 {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Error1")?;
        Ok(())
    }
}

impl Error for Error2 {}
impl Display for Error2 {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Error2")?;
        Ok(())
    }
}

fn heterogeneous_into_anyhow() {
    let x = try bikeshed anyhow::Result<_> { err1("1")? + err2("2")? };
    let y = try bikeshed anyhow::Result<_> { err2("1")? + err1("2")? };
    assert_eq!(x.unwrap(), y.unwrap());
}

Guide-level explanation

Assuming the explanation for try blocks is implemented as per RFC 3721, which contains:

This behaviour is what you want in the vast majority of simple cases. In particular, it always works for things with just one ?, so simple things like try { a? + 1 } will do the right thing with minimal syntactic overhead. It's also common to want to group a bunch of things with the same error type. Perhaps it's a bunch of calls to one library, which all use that library's error type. Or you want to do a bunch of io operations which all use io::Result. Additionally, try blocks work with ?-on-Option as well, where error-conversion is never needed, since there is only None.

It will fail to compile, however, if not everything shares the same error type. Suppose we add some formatting operation to the previous example:

let pair_result = try {
    let a = std::fs::read_to_string("hello")?;
    let b = std::fs::read_to_string("world")?;
    let c: i32 = b.parse()?;
    (a, c)
};

The compiler won't let us do that:

error[E0308]: mismatched types
  --> src/lib.rs:14:32
   |
   |     let c: i32 = b.parse()?;
   |                           ^ expected struct `std::io::Error`, found struct `ParseIntError`
   = note: expected enum `Result<_, std::io::Error>`
              found enum `Result<_, ParseIntError>`
note: return type inferred to be `Result<_, std::io::Error>` here
  --> src/lib.rs:14:32
   |
   |     let a = std::fs::read_to_string("hello")?;
   |                                             ^

For now, the best solution for that mixed-error case is the same as before: to refactor it to a function.

replace the final sentence with ...

While it may be obvious, or even irrelevant, to you which error type pair_result could potentially have, the compiler has no way to know this.

Just like in other situations where the compiler cannot safely infer the exact type to use, you must annotate the block with a valid error type. We've already mentioned that Result automatically converts between error types where a suitable implementation of Into exists and you can leverage this and write:

let pair_result = try bikeshed Result<_, PairError> {
  let a = std::fs::read_to_string("hello")?;
  let b = std::fs::read_to_string("world")?;
  let c: i32 = b.parse()?;
  (a, c)
};

As long as you have defined a suitable error:

enum PairError {
    IoError(Box<io::Error>),
    ParseError(Box<num::ParseIntError>),
}

impl From<io::Error> for PairError {
    fn from(e: io::Error) -> Self {
        Self::IoError(Box::new(e))
    }
}

impl From<num::ParseIntError> for PairError {
    fn from(e: num::ParseIntError) -> Self {
        Self::ParseError(Box::new(e))
    }
}

Of course, there are crates available to simplify this if you do not want or need to create your own custom error type.

Reference-level explanation

This described the experimental implementation, currently in place in nightly, as implemented by scottmcm in PR 149489 with occasional additional comments by the RFC author.

Compiler changes

Extend ast::ExprKind::TryBlock to store optional return type

pub enum ExprKind {
  ...
  // previously: TryBlock(Box<Block>),
  TryBlock(Box<Block>, Option<Box<Ty>>),
  ...
}

with associated adjustments to visit::walk_fn, assert!

Parse & pretty-print syntax try bikeshed T {...}

  1. Add (temporary) bikeshed keyword (see unresolved-questions) to available Tokens

    enum TokenType {
    ...
        SymBikeshed,
    ...
    }
    
    macro_rules! exp {
    ...
        (Bikeshed) => { exp!(@sym, bikeshed, SymBikeshed) };
    ...
    }
    
  2. Add bikeshed & try_blocks_heterogeneous spanned symbols

    symbols! {
    ...
        Symbols {
        ...
            bikeshed,
        ...
            try_blocks_heterogeneous,
        }
    ...
    }
    
  3. Parse try blocks with optional bikeshed keyword

    /// Parses a `try {...}` or `try bikeshed Ty {...}` expression (`try` token already eaten).
    fn parse_try_block(&mut self, span_lo: Span) -> PResult<'a, Box<Expr>> {
        // ADD: do we have an annotated type?
        let annotation =
            if self.eat_keyword(exp!(Bikeshed)) { Some(self.parse_ty()?) } else { None };
    
        let (attrs, body) = self.parse_inner_attrs_and_block(None)?;
        if self.eat_keyword(exp!(Catch)) {
            Err(self.dcx().create_err(errors::CatchAfterTry { span: self.prev_token.span }))
        } else {
            let span = span_lo.to(body.span);
            //ADD: homogeneous & heterogeneous try blocks are behind separate feature gates
            let gate_sym =
                if annotation.is_none() { sym::try_blocks } else { sym::try_blocks_heterogeneous };
            
            self.psess.gated_spans.gate(gate_sym, span);
            Ok(self.mk_expr_with_attrs(span, ExprKind::TryBlock(body, annotation), attrs))
        }
    }
    
    fn is_try_block(&self) -> bool {
        self.token.is_keyword(kw::Try)
            && self.look_ahead(1, |t| {
                *t == token::OpenBrace
                    || t.is_metavar_block()
                    // ADD: optional `bikeshed` following `try`
                    || t.kind == TokenKind::Ident(sym::bikeshed, IdentIsRaw::No)
            })
            && self.token_uninterpolated_span().at_least_rust_2018()
    }
    
  4. Correctly pretty print bikeshed annotated try blocks

    ast::ExprKind::TryBlock(blk, opt_ty) => {
        let cb = self.cbox(0);
        let ib = self.ibox(0);
        self.word_nbsp("try");
        // ADD: if there is a type annotation, prefix with `bikeshed`
        if let Some(ty) = opt_ty {
            self.word_nbsp("bikeshed");
            self.print_type(ty);
            self.space();
        }
        self.print_block_with_attrs(blk, attrs, cb, ib)
    }
    

Desugaring

  1. Introduce TryBlockScope enum

    // The originating scope for an `Expr` when desugaring `?`
    enum TryBlockScope {
        /// There isn't a `try` block, so a `?` will use `return`.
        Function,
        /// We're inside a `try { … }` block, so a `?` will block-break
        /// from that block using a type depending only on the argument.
        Homogeneous(HirId),
        /// We're inside a `try bikeshed _ { … }` block, so a `?` will block-break
        /// from that block using the type specified.
        Heterogeneous(HirId),
    }
    
  2. Update desugaring try blocks at definition site

    /// Desugar `try { <stmts>; <expr> }` into `{ <stmts>; ::std::ops::Try::from_output(<expr>) }`,
    /// `try { <stmts>; }` into `{ <stmts>; ::std::ops::Try::from_output(()) }`
    /// and save the block id to use it as a break target for desugaring of the `?` operator.
    fn lower_expr_try_block(&mut self, body: &Block, opt_ty: Option<&Ty>) -> hir::ExprKind<'hir> {
        let body_hir_id = self.lower_node_id(body.id);
        
        // ADD differentiation
        let new_scope = if opt_ty.is_some() {
            TryBlockScope::Heterogeneous(body_hir_id)
        } else {
            TryBlockScope::Homogeneous(body_hir_id)
        };
        let whole_block = self.with_try_block_scope(new_scope, |this| {
            let mut block = this.lower_block_noalloc(body_hir_id, body, true);
    ...
            this.arena.alloc(block)
        });
    
        // ADD identification of `try bikeshed` as typed blocks
        if let Some(ty) = opt_ty {
            let ty = self.lower_ty(ty, ImplTraitContext::Disallowed(ImplTraitPosition::Path));
            let block_expr = self.arena.alloc(self.expr_block(whole_block));
            hir::ExprKind::Type(block_expr, ty)
        } else {
            hir::ExprKind::Block(whole_block, None)
        }
    }
    
  3. Update desugaring ?, specifically in the construction of the ControlFlow::Break arm and the final return value

    /// Desugar `ExprKind::Try` from: `<expr>?` into:
    /// ```ignore (pseudo-rust)
    /// match Try::branch(<expr>) {
    ///     ControlFlow::Continue(val) => #[allow(unreachable_code)] val,,
    ///     ControlFlow::Break(residual) =>
    ///         #[allow(unreachable_code)]
    ///         // If there is an enclosing `try {...}`:
    ///         break 'catch_target Residual::into_try_type(residual),
    ///         // Otherwise:
    ///         return Try::from_residual(residual),
    /// }
    /// ```
    fn lower_expr_try(&mut self, span: Span, sub_expr: &Expr) -> hir::ExprKind<'hir> {
    ...
        // `ControlFlow::Break(residual) =>
        //     #[allow(unreachable_code)]
        //     return Try::from_residual(residual),`
        let break_arm = {
        ...
    
            //  (hir::LangItem, Result<HirId, LoopIdError>)
            let (constructor_item, target_id) = match self.try_block_scope {
                TryBlockScope::Function => {
                    (hir::LangItem::TryTraitFromResidual, Err(hir::LoopIdError::OutsideLoopScope))
                }
                TryBlockScope::Homogeneous(block_id) => {
                    (hir::LangItem::ResidualIntoTryType, Ok(block_id))
                }
                // `try bikeshed` treated like a function for construction of residual expression,
                // but with available HirId for the source block
                TryBlockScope::Heterogeneous(block_id) => {
                    (hir::LangItem::TryTraitFromResidual, Ok(block_id))
                }
            };
            let from_residual_expr = self.wrap_in_try_constructor(
                // replace previous inline `if let Some() else` to differentiate try block vs function
                constructor_item,
                try_span,
                self.arena.alloc(residual_expr),
                unstable_span,
            );
            // replace `if let Some() else` with `if .is_ok() else` to differentiate try block vs function
            let ret_expr = if target_id.is_ok() {
    ...
        // originating scope: try blocks vs function
        match self.try_block_scope {
            TryBlockScope::Homogeneous(block_id) | TryBlockScope::Heterogeneous(block_id) => {
                hir::ExprKind::Break(
                    hir::Destination { label: None, target_id: Ok(block_id) },
                    Some(from_yeet_expr),
                )
            }
            TryBlockScope::Function => self.checked_return(Some(from_yeet_expr)),
        }
    }
    

Drawbacks

This adds further syntax complexity to the language with another, slightly different, way in which types must be annotated. The open question on the correct syntax shows that whatever is chosen it will not be immediately obvious to users.

Rationale and alternatives

Homogeneous try-blocks with manual error conversion

Only support homogeneous try blocks and force manual conversion.

For example, you could do something like

try {
        err1("1").map_err(Into::<anyhow::Error>::into)?
            + err2("2").map_err(Into::<anyhow::Error>::into)?
    };
  1. This leads to much more verbose code where multiple error types are involved.
  2. In cases where the final Residual is not any of the Residuals inside the try block (likely a very common situation with anyhow) this creates further verbosity by forcing turbofish annotation in at least one place.
  3. Changing the block Residual requires multiple adjustments.
  4. This breaks for cases where the Try type in question is not Result/Option unless it implements an equivalent of map_err().
  5. It is not immediately obvious to the user reading the block definition what the resulting Residual will be, the information is inside the block, not at the start of the definition. When you see try bikeshed Foo { you know the type without analysing the block.

Fold through some type function that attempts to merge residuals

This is much less local, complex to implement and removes control from the user.

Why not use type annotated variables?

Why not instead just fix type inference so that let x: Result<_, B> = try {} works, as well as variants like returning the try value, etc.?

~lyphyser commenting on this RFC

Updating type inference to cover these cases is a much more significant undertaking. Leveraging existing type annotation possibilities is not always practical.

  1. For cases where this is possible it is not always desirable:

    let foo: Result<_, Error1> = try { ... };
    let foo = foo?;
    

    is more verbose, more brittle and less clear than:

    let foo = try bikeshed Result<_, Error1> { ... }?;
    
  2. Other times this is only possible with a more significant amount of extra effort, e.g. an anonymous future created by an async block. (Or inside an anonymous closure, e.g. in a call to .map()).

    let run_loop = async {
        ...
        try bikeshed TryType { 
            loop {
                break_if_this_fails().await?;
                ...
            }
        }
    }
    block_on(run_loop)?;
    

See also

Could we evolve this in future?

Once the correct keyword / syntax is identified the remainder is an early desugaring to existing features, this is the easiest kind of thing to change over editions.

Therefore, if in the future we get new type system features that would allow improved "fallback hinting" or inference of unannotated try blocks, we could relax the restrictions on homogeneous try blocks while still maintaining the ability to annotate for explicit clarity or where inference is not possible. This would be easy to achieve over an edition change, but we don't need to wait for an unknown to ship something now; we can switch how it works later easily enough.

Prior art

Languages with traditional exceptions don't return a value from try blocks, so don't have this problem. Even checked exceptions are still always the Exception type.

In C#, the ?. operator is scoped without a visible lexical block.

Related RFCs & experimental features

Unresolved questions

  • [ ] What should the syntax be? See Issue #154128 for discussion of alternatives (:, ->, as, nothing just try T {}, or even try ☃️ {...} as in RFC3721)
  • [ ] What type should be annotated? This should probably be the full type, with optional inference, as currently implemented for bikeshed, but see Issue #154127 for discussion.

Future possibilities

Allow inference via function return type or variable binding

For cases such as

fn heterogeneous_via_return_type() -> Result<(), Error1> {
    let x = try { err1("1")? + err2("2")? }?;
    let y = try { err2("1")? + err1("2")? };
    let _: Result<_, Error2> = try { err2("1")? + err1("2")? };
    assert_eq!(x, y?);
    Ok(())
}

where the errors involved all implement Into<Error1>