External Publication
Visit Post

Pre-RFC improved ergonomics for `!`

Rust Internals [Unofficial] May 22, 2026
Source

robofinch:

As for relying on Try… it seems like implementing this mapping/coercion would get very overengineered.

This conversation has been really valuable, thanks!

I've spent the last couple of days working through why Option<io::Result<!>> feels so different to Vec<io::Result<!>>. The latter is certainly not something that should be implicitly converted to a Vec<io::Result<MasssiveStruct>> ...

From that and more experimentation I've realised that this feeling of unergonomic developer experience comes about from the interplay of two ! aspects with ? (which already uses Try under the hood).

Compare the following (without !) playground

fn ignore_blocking_not_never(err: io::Error) -> io::Result<std::convert::Infallible> {
    Err(err)
}

// Recognition of ! as infallible appears much earlier in process than Infallible
pub fn process_return_result_not_never(input: u32) -> io::Result<u32> {
    let io_function = Ok(input);
    match io_function {
        Ok(_) => io_function,
        Err(e) => {
            let r = ignore_blocking_not_never(e); // InferredType `r: io::Result<Infallible>`
            let _b = r?; // InferredType `_b: Infallible`
            Err(io::Error::other("Infallible is not recognised as divergent by HIR, only at MIR"))
        }
    }
}

with the ! equivalent playground

fn ignore_blocking(err: io::Error) -> Option<io::Result<!>> {
    match err.kind() {
        // This could just as easily be any error we want to ignore and move on
        // (e.g. `PermissionDenied | ReadOnlyFileSystem | IsADirectory`) when updating
        // "all available files". Possibly with a call to `info!()` to log.
        io::ErrorKind::WouldBlock => None,
        _ => Some(Err(err)),
    }
}

// To show the confusion & relevance to ! from a slightly different perspective
pub fn process_return_result_long(input: u32) -> io::Result<u32> {
    let io_function = Ok(input);
    match io_function {
        Ok(_) => io_function,
        Err(e) => {
            let o: Option<Result<!, io::Error>> = ignore_blocking(e);
            let r = o.unwrap(); // InferredType `r: io::Result<!>`
            let _b = r?; // InferredType `_b: io::Result<u32>`
            #[allow(unreachable_code)]
            _b // With ! this is unreachable
        }
    }
}

While Infallible is recognised by the MIR as unreachable (as confirmed running "show MIR" on the by the first playground) the HIR doesn't see this, so the user is used to adding unreachable!() or similar to avoid a compiler error.

With ! the code is recognised as unreachable by the HIR which then leads to a linter error. That's a major UX change from "compiler error if you don't" to "linter error if you do" (add a return)

Also - the inferred return types of _b are significantly different (for the same reason)

Now couple this with the appearance of automatic coercion from un-nested Try-types (seen above in the type of _b and more explicitly in the return to the original example below) playground

// Easier to see what is going on if we explicitly use try blocks
pub fn process_try_try(input: u32) -> Option<io::Result<u32>> {
    let io_function = Ok(input);
    match io_function {
        Ok(_) => Some(io_function),
        Err(e) => {
            try {
                let o: Option<io::Result<!>> = ignore_blocking(e);

                // It _looks like_ this coerces an Option<io::Result<!>>::None,
                // to an Option<io::Result<u32>>::None, but see below for what
                // is really happening
                let r: io::Result<!> = o?;

                // And this _appears to_ coerce an io::Result<!>::Err to an
                // io::Result<u32>::Err, but, again, see below for reality.
                try { r? }
            }
        }
    }
}

// Which desugars and simplifies to:
pub fn process_desugared(input: u32) -> Option<io::Result<u32>> {
    type OptionResultNever = Option<io::Result<!>>;
    type ResultNever = io::Result<!>;
    type OptionResultU32 = Option<io::Result<u32>>;
    type ResultU32 = io::Result<u32>;

    let io_function = ResultU32::Ok(input);
    match io_function {
        Ok(_) => OptionResultU32::Some(io_function),
        Err(e) => {
            let outer_try: OptionResultU32 = 'outer_try: {
                let o: OptionResultNever = ignore_blocking(e);
                let r: ResultNever = match o {
                    OptionResultNever::Some(r) => r,
                    // Automatic, hidden, explicit type conversion in desugared version
                    OptionResultNever::None => break 'outer_try OptionResultU32::None,
                };
                let inner_try: ResultU32 = match r {
                    // Automatic, hidden, explicit type conversion in desugared version
                    ResultNever::Err(e) => ResultU32::Err(e),
                };
                // Automatic, hidden, explicit type conversion in desugared version
                OptionResultU32::Some(inner_try)
            };

            outer_try
        }
    }
}

So, what I'm tending towards at the moment, is that the real ergonomics issue (and point of likely confusion) is with nested Try types and !. That feels like it's pointing towards a specific solution, maybe fully implicit, maybe requiring a minimal explicit syntax involving ? ... (Not quite there yet but putting this thought process out there, both to help me structure thoughts and see where it resonates with / triggers others)

Discussion in the ATmosphere

Loading comments...