rfcs icon indicating copy to clipboard operation
rfcs copied to clipboard

Add `homogeneous_try_blocks` RFC

Open scottmcm opened this issue 1 year ago • 19 comments

Tweak the behaviour of ? inside try{} blocks to not depend on context, in order to work better with methods and need type annotations less often.

The stable behaviour of ? when not in a try{} block is untouched.

Rendered

scottmcm avatar Oct 30 '24 07:10 scottmcm

Big +1 on "the common case should just work". We could just have method for people who want the type-changing case, like foo().bikeshed_change_try_type()?, right?

Nadrieril avatar Oct 30 '24 08:10 Nadrieril

@scottmcm Can you clarify why this is a T-libs-api RFC in addition to a T-lang one? The implementation of this might involve changes to the traits underlying Try, but it seems like the key purpose of this RFC is "how should try and ? interact", rather than the implementation of that.

joshtriplett avatar Oct 30 '24 08:10 joshtriplett

Should we wait a few weeks before nominating for T-lang? A few recent RFCs felt rushed to me because they were FCPed very quickly. I'd like to see more comments from the community before, especially when this is pushed by a lang member

Nadrieril avatar Oct 30 '24 12:10 Nadrieril

Nominating only makes it show up on the generated meeting agendas for T-lang triage, but given the pace of those meetings in recent years it doesn't really mean that this RFC will necessarily be discussed at the next meeting, or even in the next 3 meetings. And the RFC theoretically could be discussed at the meeting even without it being nominated because people can just bring up topics they feel need to be discussed. There's basically no reason to not nominate this if 2 lang members already take this proposal seriously.

Lokathor avatar Oct 30 '24 13:10 Lokathor

Could it be possible to request this as a fallback for the closure (and async blocks) case too? Adding the try {...}? block as a workaround is not that much different from specifying the type of the Err, it's still an annoying papercut.

oriongonza avatar Oct 30 '24 14:10 oriongonza

@Nadrieril Good point! The majority case there is just .map_err(Into::into) -- I've added a section. We could probably do something similar for residual-to-residual conversion too, though it's unclear how commonly needed that would be.

@dev-ardi Unfortunately "fallback" in the type system is extremely complicated, especially as things get nested. If you have a try block inside another try block, for example, deciding which one falls back is non-obvious. See also multiple years of discussion about wishing we could have "fallback to usize" for indexing, for example, in order to allow indexing by more types without changing how existing code infers.

My thought here is that the try marker is particularly tolerable for the closure case, because adding the try{} also allows removing the Ok() from the end. So in the unit case, using try actually means fewer characters than a fallback rule.

scottmcm avatar Oct 30 '24 15:10 scottmcm

@joshtriplett I originally just put lang, but then thought "well but I did put traits in here", so added libs-api too in case.

You're absolutely right that my intent here is about the interaction, rather than the detail. So if libs-api wishes to leave this to lang, that's fine by me. I just didn't want to assume that.

scottmcm avatar Oct 30 '24 16:10 scottmcm

What would be the downside of adding this desugaring (make_try_type()) to everything? That should solve the issue with closures and async bocks too, right?

oriongonza avatar Oct 30 '24 16:10 oriongonza

@scottmcm I think it's a very nice property of this RFC: it also solves the issue of error type inference for closures and async blocks in a backwards-compatible and syntactically light way. It's kind of obvious, but could you add it as an explicit supporting use case in the RFC? My first thought was that the default error type mechanism could be extended to those cases, but this RFC obviates such additions.

afetisov avatar Nov 07 '24 13:11 afetisov

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 avatar Nov 10 '24 19:11 purplesyringa

I think that changing behavior of ? in try blocks is a really big drawback of this proposal. It introduces a very visible inconsistency to the language, which arguably will trip beginners and sometimes even experienced developers. The error conversion case mentioned by @purplesyringa is also quite common in application-level code.

I think we need a more consistent solution which will work for both closures and try blocks. I don't know if it was discussed previously, but can't we collect all error types used with ? and if all error types inside a block are concrete and the same, then use this type? If at least one type is different, then an explicit type annotation will be required.

For example:

fn foo() -> Result<(), E1> { ... }
fn bar() -> Result<(), E1> { ... }
fn baz() -> Result<(), E2> { ... }
fn zoo<E: Error>() -> Result<(), E> { ... }

// Works. Evaluates to `Result<u32, E1>`
let val = try {
    foo()?;
    bar()?;
    42u32
};

// This also works. Because we "bubble" `Result`, this block evaluates to `Result<(), E1>`
let val = try {
    foo()?;
    bar()?;
};

// Same for closures. The closure returns `Result<u32, E1>`.
let f = || {
    foo()?;
    bar()?;
    Ok(42u32)
}; 

// Compilation error. Encountered two different "break" types (`E1` and `E2`),
// additional type annotations are needed
let val = try {
    foo()?;
    baz()?;
    42u32
};

// Compilation error. Generics also require explicit annotations.
// Same for mixing `Option` and `Result` in one block.
let val = try {
    foo()?;
    zoo()?;
    42u32
};

In the case of nested try blocks the same principle will apply recursively starting from "leaf" blocks.

newpavlov avatar Nov 26 '24 01:11 newpavlov

can't we collect all error types used with ? and if all error types inside a block are concrete and the same, then use this type

I admit I have no idea how rustc handles this, but I believe this is complicated due to type inference. Generics might be parametric in the return type, so there's no "type" you can "collect" before settling on the type, at which point it's too late to reconsider the choice.

I think what you want is a fallback in type inference: try to union all return types (as in HM; I'm not sure if that's how rustc handles this), and only consider Into conversions if this fails. I can imagine that implementing fallbacks would require major changes in the compiler.

purplesyringa avatar Nov 26 '24 04:11 purplesyringa

Hi team,

This RFC has been open for an almost a year, and it addresses a genuinely painful ergonomics issue with try blocks that many developers encounter when working with nightly

Importantly, this RFC directly solves one of the explicit stabilization blockers listed in the try blocks tracking issue (https://github.com/rust-lang/rust/issues/31436):

Address issues with type inference (try { expr? }? currently requires an explicit type annotation somewhere)

This isn't just a nice-to-have ergonomic improvement – it's solving a documented requirement for try blocks stabilization

I’d really appreciate if someone from the team could share any insights on whether there are specific blockers preventing this RFC from moving forward (e.g., design concerns, implementation complexity, etc.)

Thanks so much for your time and all the hard work on this!

Kivooeo avatar Aug 08 '25 12:08 Kivooeo

I'm moving this from lang-radar to lang-nominated since our nomination queue is finally close to empty and this RFC should be a priority.

(This should wait for a meeting where @scottmcm is available to discuss.)

joshtriplett avatar Aug 08 '25 19:08 joshtriplett

We talked about this in the lang call today. We were happy to see experimental work proceed that would help with refining the RFC. We'll combine and track this with the work for try { .. } and the Try trait in:

  • https://github.com/rust-lang/rust/issues/31436
  • https://github.com/rust-lang/rust/issues/84277

traviscross avatar Aug 20 '25 23:08 traviscross

Would it be possible to preserve the old behavior if the try block's type is explicitly annotated? When using anyhow::Error (or other "coalescing" error types), it would often be less boilerplate to just annotate the the try block's type, rather than add .context() or whatever to every single ? usage site. I use this pattern very often in my Rust GUI applications:

let result: anyhow::Result<()> = try {
    // These calls all might return different result types, but they all convert to `anyhow::Result`
    foo()?;
    bar()?;
    baz()?;
    ...
};
if let Err(e) = result {
    show_error_message_popup(e);
}

crumblingstatue avatar Sep 20 '25 07:09 crumblingstatue

@crumblingstatue See this part of the future possibilities section in the RFC: https://github.com/scottmcm/rfcs/blob/if-at-first-you-dont-succeed/text/3721-homogeneous-try-blocks.md#annotated-heterogeneous-try-blocks

Basically, probably not with the annotation where you had it in your example, but yes in a very similar place as part of the try block itself, rather than external to the try block.

scottmcm avatar Sep 23 '25 08:09 scottmcm

How to teach this

As I understand it, homogeneous try blocks are like match or if else blocks, where the first branch defines the type of the expression. In contrast, heterogeneous try blocks behave more like our current alternative solution, where an Immediately Invoked Function/Future Expression (IIFE) can determine the expression’s type from the tail.

The error type conversions like:

? Try::branch() breaks Residual<?>::TryType End of the try block (Try::from_output(..))
Option<_> Option<!> Option<?> Option<T>
Result<_, E1> Result<!, E1> Result<?, E1> Result<T, E1> (First branch)
Result<_, E2> Result<!, E2> Expected E1, found E2 Error (Heterogeneous branches)

The IIFE & heterogeneous try blocks:

? Try::branch() breaks FromResidual::from_residual(r) End of the block
Option<_> Option<!> Cannot infer ? as FromResidual<Option<!>> Need annotation
Result<_, E1> Result<!, E1> Cannot infer ? as FromResidual<Result<!, E1>> Need annotation
// $ty supplement $branches (if any generic is not annotated yet)
// The error type is inferred from the first branch.
let _: $ty = try { $branches };
let _: $ty = match $scrutinee { _ => $branches };
let _: $ty = if $condition { $branch1 } else { $branch2 };

// $branches should follow $ty hints (if the return statement is not annotated yet)
// (And currently, the try block's "return statement" is never annotated with the error type.)
let _: $ty = (|| { $branches })();
let _: $ty = async { $branches }.await;

// heterogeneous try blocks
let _: $ty = try ☃️ $ty { $branches };

Please correct me if there's any mistake.

KmolYuan avatar Sep 23 '25 15:09 KmolYuan

We were happy to see experimental work proceed that would help with refining the RFC.

Posted https://github.com/rust-lang/rust/pull/148725 to implement the proposed behaviour on nightly.

It confirms that none of the try blocks in compiler/ were incompatible.


EDIT: furthermore, it confirms that none of the type annotations on the blocks (let blah: Type = try { … };) were needed by ?s -- the only one that wasn't trivially removable was a try block that never actually used ? (for more see https://github.com/rust-lang/rust/issues/31436#issuecomment-3536205915).

scottmcm avatar Nov 09 '25 04:11 scottmcm