Pre-RFC improved ergonomics for `!`

I would love to gather your feedback to the following idea. When I posted this on zulip the comments I received were:

  1. "Foo<!> might be inhabited" - I think this proposal works with that consideration
  2. "show more about where such things are showing up for you and how you ended up with those types in the first place" - the below pre-RFC ended up a chunk longer than I was expecting but hopefully addresses this

My ask to you:

  1. Does this make sense as a proposal, how would you feel about it?
  2. Why won't this work? Save me a lot of effort by shooting it down now.
  3. Would you be willing to help out as a mentor or just give any advice to someone who's writing their first RFC (I post-documented scottmcm's work on try bikeshed, so technically I'm the "author" of the pending RFC for that but it's all their work so I'm not counting it)

Pre-RFC: improved ergonomics for !

Summary

Allow ! to be used in mainstream code to signify an impossible value without introducing "more work than it's worth". Up to now most of my mainstream usage of ! has brought reduced ergonomics as the cost of accurate typing.

I propose to provide a limited form of coercion for the most common & painful usages of !, in a way which moves the discussion away from whether Foo<!> is inhabited. I imagine that the implementation would occur reasonably early in the compilation alongside type-inferance and bounds validation. I would be more than happy to put in the work to research, identify, discuss, implement and shepherd such a change (but would be very grateful if I could find a willing mentor).

Motivation

The stabilisation of never is (hopefully) just around the corner (a huuuuge thank you to everyone who has been part of getting it this far). Please please, please do not take this as a criticism - rather a compliment as to how valuable your efforts are to people like me who love to code in rust (you may get a feeeling for how excited I am to be able to make even more use of !).

We should expect increased use of ! in the future to explicitly highlight situations which cannot occur. Currently, using ! to accurately and explicitly anchor this information in the type system and lead to unfortunate foot guns.

In the past 2 months I have run into the following situations where ! is the right answer, but not the pragmatic answer.

Examples

Async: reset io readiness & Poll::Pending

Before using an io connection it is often necessary to check readiness. These checks can leave the connection in an undesired state and need to be reset if not used.

A related clear function can (semantically) only return Poll::Pending or Poll::Ready(Err). Any form of Poll::Ready(Ok) is meaningless. As such the *correct_ signature would be fn clear_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<!>>;, which fully conveys these semantics without users needing to read the full set of notes in the documentation.

This signature, however, causes issues down the road, for example when implementing Stream

playground

/// Async polling for a socket
trait PollableSocket
where
    Self: Sized,
{
    /// Clear the readiness state of the underlying socket.
    ///
    /// **This MUST be called after any failed readiness poll.**
    ///
    /// Implementations should attempt to clear the relevant readiness marker of the underlying
    /// socket and then return:
    /// - `Poll::Pending` if successful
    /// - `Poll::Ready(error)` on error, to avoid repeated polling without handling the error
    fn clear_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<!>>;

    /// Check whether the socket is ready.
    ///
    /// ## Note
    ///
    /// You **MUST** call self.clear_ready() in the following cases:
    ///
    /// - If this fails it may leave the socket in an undefined readiness state.
    /// - If you do not make use of the readiness it will remain blocked in that state.
    fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<Ready>>;
}

impl Stream for MySocket {
    type Item = io::Result<String>;

    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
        match ready!(self.as_mut().poll_ready(cx)) {
            Ok(readiness) if readiness.contains(Ready::READ) => todo!("read and stream"),
            _ => self.clear_ready(cx).map_ok(|x| x).map(Some), // <- .map_ok(|x| x) to coerce ! to String
        }
    }
}

Note that the call to clear ready needs to be followed by a no-op .map_ok(|x| x) in _ => self.clear_ready(cx).map_ok(|x| x).map(Some).

In this case we are lucky that Poll offers a convenience function .map_ok() to manipulate the wrapped result. Most types do not.

Without this convenience (or the convenience of ready!) the code expands to a verbose match:

_ => match self.clear_ready(cx) {
    Poll::Pending => Poll::Pending,
    Poll::Ready(Err(e)) => Poll::Ready(Some(Err(e))),
}

This may seem trivial when reading later. The surrounding code is, by it's very nature, inherently complex; the requirement to add a no-op map adds a completely different dimension of complexity and thus risk, requiring the user to context-switch (I certainly found this cognitively taxing and something that completely threw my focus from the actual implementation).

Infallible conversions & trait bounds

The second case is probably going to be more common in the wild. While implementing a parsing library I:

  • Defined a custom error type
  • Created a series of custom types to represent the parsed data
  • Implemented FromStr for those custom types
  • Added a basic marker-ish trait Headerwith any type-specific implementation details (e.g. the header key)
  • Added HeaderExt with a blanket impl to parse the value from a header structure

So far ... nothing magical or unusual. The issue arises around how to handle cases where FromStr is infallible.

The *right_ way to do this would be:

impl FromStr for DeviceType {
    type Err = !;
    ...

Then it is clearly defined in the type system that this conversion can never fail, which again fully conveys the semantics without users needing to read the full set of notes in the documentation.

However, this means that the blanket

impl<H, E> HeaderExt for H
where
    H: Header + FromStr<Err = E>,
    HeaderErr: From<E>,

will not trigger.

Here is a full skeleton example playground

#![allow(dead_code)]
#![allow(unused_variables)]
#![feature(never_type)]

use std::str::FromStr;

enum HeaderErr {
    ParseError,
}

enum DeviceType {
    AudioController,
    Custom(String),
}

impl FromStr for DeviceType {
    // We have a `Custom` type so this will never fail
    type Err = !;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let devicetype = match s {
            "AudioController" => Self::AudioController,
            _ => Self::Custom(s.to_string()),
        };
        Ok(devicetype)
    }
}

trait Header {}

impl Header for DeviceType {}

trait HeaderExt
where
    Self: Sized,
{
    /// Parse data from a header line ()
    fn parse_header(header: &str) -> Result<Self, HeaderErr>;
}

impl<H, E> HeaderExt for H
where
    H: Header + FromStr<Err = E>,
    HeaderErr: From<E>,
{
    /// Parse data from a header line ()
    fn parse_header(header: &str) -> Result<H, HeaderErr> {
        let (data, checksum) = header.split_once(", sha:").ok_or(HeaderErr::ParseError)?;
        Ok(data.parse()?)
    }
}

fn main() {
    // let device =
    //     DeviceType::parse_header("AudioController, sha:040f4bf53d2ca137d6f767169cdb2fa62849b156");
}

// error[E0599]: the variant or associated item `parse_header` exists for enum `DeviceType`, but its trait bounds were not satisfied
//   --> examples/conversion.rs:57:21
//    |
// 11 | enum DeviceType {
//    | --------------- variant or associated item `parse_header` not found for this enum
// ...
// 57 |         DeviceType::parse_header("AudioController, sha:040f4bf53d2ca137d6f767169cdb2fa62849b156");
//    |                     ^^^^^^^^^^^^ variant or associated item cannot be called on `DeviceType` due to unsatisfied trait bounds
//    |
// note: the following trait bounds were not satisfied:
//       `&DeviceType: Header`
//       `&DeviceType: std::str::FromStr`
//       `&mut DeviceType: Header`
//       `&mut DeviceType: std::str::FromStr`
//       `<&DeviceType as std::str::FromStr>::Err = _`
//       `<&mut DeviceType as std::str::FromStr>::Err = _`
//   --> examples/conversion.rs:45:8
//    |
// 43 | impl<H, E> HeaderExt for H
//    |            ---------     -
// 44 | where
// 45 |     H: Header + FromStr<Err = E>,
//    |        ^^^^^^   ^^^^^^^^^^^^^^^^
//    |        |        |       |
//    |        |        |       unsatisfied trait bound introduced here
//    |        |        unsatisfied trait bound introduced here
//    |        unsatisfied trait bound introduced here
//    = help: items from traits can only be used if the trait is implemented and in scope
// note: `HeaderExt` defines an item `parse_header`, perhaps you need to implement it
//   --> examples/conversion.rs:35:1
//    |
// 35 | trait HeaderExt
//    | ^^^^^^^^^^^^^^^

There are two ways around this:

  1. (The lazy one) just define

    /// We have a `Custom` type so this will never *actually* fail
    impl FromStr for DeviceType {
        type Err = HeaderErr;
    ...
    
  2. (The right way, which currently compiles but adds another case of future collision with the planned blanket impl in #64715) add

    impl From<!> for HeaderErr {
        fn from(value: !) -> Self {
            match value {}
        }
    }
    

Option wrapping

It doesn't take a large amount of imagination to envision Option<Result<!,E>> or Option<Result<T,!>> resulting from similar starting situations to the above examples. Would the recommendation for Option<Result<!,E>> be:

  • nested maps: .map(|r| r.map(|never| never))
  • double transposition: .transpose().map(|_never| None).transpose()
  • map try: .map(|e| try {e?})
  • Don't use Result<!,E> to represent 'only returns on error' but stick with Result<(),E> which was used before we had !

And for those wondering where this would come from, I originally split out a common error handler in the async example above, but then just inlined it instead: playground

#![feature(never_type)]
#![feature(try_blocks)]
use std::io;

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)),
    }
}

pub fn process(input: u32) -> Option<io::Result<u32>> {
    let io_function = Ok(input);
    match io_function {
        Ok(_) => Some(io_function),
        Err(e) => ignore_blocking(e) // hopefully in future we can just add a `,` here
        .map(|e| try {e?}), // currently we need to convert Option<Result<!>> to Option<Result<u32>>
    }
}

(Yes the error handler could just return Option<io::Error> and leave it to the caller to wrap in a Result, but wouldn't it be nicer to hand back a type structure that the caller can simply use?)

Why bother? - there are clear workarounds for each case

! is great! It extends the language to provide a clear way to idiomatically express intent. From the point of view of a general language user, I'd consider it as valuable as None (is not Null) and Result (is neither a tuple nor an exception) in this regard. It therefore deserves a focus on integrative ergonomics in the surrounding language, separately from the core implementation.

  1. We should expect Result, fallible traits and error-handlers to be the most common cases where people begin to use !. If these obvious usages cause "pain" shortly down the road then, sadly, most will simply replace ! with a dummy value/type and move on.
  2. All the reasonable workarounds rely on some form of map function. Poll makes this easiest by providing a map to the inside T of a double-wrapped Poll<Option<Result<T,E>>>; Option doesn't offer this (for good reasons) but at least has its own map which allows chaining. As Try nears stabilisation and then gets into stable we should expect an increased number of custom wrapper types; many of which may not think to offer a map. This leaves the user stuck with verbose match destructuring; or avoiding either ! or the custom try type (or both).

Ergonomics

The 2017 [Ergonomics Initiative] lays out 3 dimensions to balance when looking at providing implicitness for reasons of ergonomics.

Applicability (4/5)

  • Strictly excluding match etc. from consideration removes the side-effects that made previous considerations impossible at the cost of slightly reduced applicability.
  • The coercion is restricted to only cover situations with <!> as a generic type, generic type bound or an associated type.

Power (2/5)

  • Converting from Foo<!> to Foo<T> will never destroy any information. Or rather, the implicit conversion will only take effect if it is safe to do so.
  • By performing this as part of the type-safety & generics analysis no runtime conversion of data occurs.
  • No memory access or implicit dereferencing occurs.

Context-Dependence (2/5)

  • By restricting to situations where type-inference is already expected the overall influence is restricted to at most the current function / trait impl boundary as return types are always explicit. The user only needs to look at two function / trait signatures which are immediately adjacent to the current code to see ! incoming and T outgoing.
  • Additionally rust-analyzer is commonly used and provides inline details of the explicit & inferred types directly in place in the code for most users.

How could this be implemented?

The HIR is currently used to perform type-inference, trait solving & type-checking. The viability of coercion requires the same data and can be verified in the HIR at the same time, probably as part of the existing steps. It may be necessary / possible to leverage some form of monomorphisation later in the MIR, or to provide targeted MIR optimisations. Right now I just have a high-level idea of where to start looking to see if I can find a viable implementation.

This won't work because Foo<!>, &! etc are not guaranteed to be uninhabited

That's less relevant given the restrictions on this solution:

  1. No usage in match etc. - so no crossover with the concerns around memory access & dereferencing in the context of unsafe code discussed in [auto-never].
  2. The compiler already has the information in the HIR and uses it for similar validations. For example see the error returned when attempting to implement map below playground:
#![feature(never_type)]
#![allow(dead_code)]

#[derive(Debug)]
struct Foo<T: HasAssocType> {
    data: T::AssocType,
}

trait HasAssocType: Sized {
    type AssocType;
}

impl HasAssocType for ! {
    type AssocType = [u8; 0];
}

impl HasAssocType for u8 {
    type AssocType = [u8; 1];
}

// // error[E0308]: mismatched types
// //   --> examples/generic.rs:43:20
// //    |
// // 38 |     fn map<U, F>(self, f: F) -> Foo<U>
// //    |            - found this type parameter
// // ...
// // 43 |         Foo{ data: f(self.data) }
// //    |                    ^^^^^^^^^^^^ expected associated type, found type parameter `U`
// //    |
// //    = note: expected associated type `<U as HasAssocType>::AssocType`
// //                found type parameter `U`
// // help: consider further restricting this bound
// //    |
// // 40 |         U: HasAssocType<AssocType = U>,
// //    |                        +++++++++++++++
//
// impl<T: HasAssocType> Foo<T> {
//     fn map<U, F>(self, f: F) -> Foo<U>
//     where
//         U: HasAssocType,
//         F: FnOnce(T) -> U,
//     {
//         Foo{ data: f(self.data) }
//     }
// }

fn main() {
    let never_foo = Foo::<!> { data: [] };

    let u8_foo = Foo::<u8> { data: [1] };

    println!("{never_foo:?}, {u8_foo:?}");
}

References

I appreciate the work you've put into coming up with a bunch of cases where ! has awkward ergonomics ... but I don't see anywhere in this pre-RFC where you actually explain the change you're proposing. What exactly do you want the compiler to do with ! that it doesn't do now? Or not do, that it does?

I'm pretty sure you meant to have a section explaining this, since the "this won't work because ..." section is responding to critics of your proposal; did you accidentally cut it when editing?

6 Likes

How is this determined? Imagine that someone had UninhabitedErr<T, E>(Result<T, E>) whose safety invariant is that E is an uninhabited 1-aligned ZST, as enforced by unsafe constructors?

Yes, this is an absurd scenario with a useless type, but it would then be unsound to coerce UninhabitedErr<T, !> to a general Uninhabited<T, E> where E may be a non-ZST or inhabited type.

I worry that determining whether adding a new coercion of this sort which affects existing code could be unsound, so it’d probably need to be opt-in… in which case I’m not sure that the solution would address all your wishes.

(Maybe UninhabitedErr could be seen as unsound for relying on negative reasoning about what coercions will be possible in future Rust versions.)

(Note: I have my “library author” hat on, I’m not a compiler dev. So, I’m used to being very paranoid to attempt to write bulletproof unsafe code, while the compiler devs probably have a better grasp of what sorts of code actually exist out there, and the language does occasionally make breaking changes after a lot of communication.)

1 Like

Thanks both. Short answer: I'm checking in on how desirable this is before investing loads of effort in getting it to actually work. I do have an idea though - see below.

Didn't cut as much as decided to focus first on assessing the overall wider desirability of such a feature.

Working out the best feasible approach (or admitting here isn't one) will be a chunk of effort on my part which I'm happy to invest if the overall proposal doesn't fall flat with "no-one really cares about this much".

Once I do that then I can flesh out the Guide-Level explanation & reference implementation and run another feedback loop looking at whether it's considered viable & sustainable by the lang team (as this would likely need a longer period before stabilising so it'll boil down to how complex the actual changes are vs. how valuable vs. how they feel about trusting a person they don't yet know who says they'll look after this through that period)

My current thinking is:

  1. Leverage (and minimally extend?) the existing check functions in the MIR to effectively validate whether implementing one of the workarounds for the Option example is safe - I'd experiment starting with those that run when creating map for the Foo that has a generic !
  2. In order of preference (again I need to experiment and research to see what is actually feasible):
    • Leverage (and minimally extend?) the existing coercion & fallback functionality, still at MIR.
    • implement a dummy version of the validated workaround in the MIR.
    • Look at leveraging generics monomorphisation to generate the required variations (if that's needed then this had better be very desirable as it would be much further down the compiler stack and risk binary-bloat. I'd guess the idea gets killed if it turns out this is the solution).

What I've seen of the compiler code while looking into this topic so far makes me feel like this has some chance of success. Enough that I'm willing to invest a chunk of my time playing around to see.

That's a good example. I've bookmarked for later (if there is a later).

If I understand correctly: constructing an UninhabitedErr<T, E> would be unsafe. Which would make the validation simple: can both types be constructed without unsafe.

I'm also coming at this as a library author with a similar view of unsafe :wink:. And "could break unsafe code" has been the killer for any previous attempts to look at this - which is a stance I'm not going to challenge (both because I agree with it and I don't have anywhere the background to do so)

While I know Rust has some implicit magic that goes away with manual impls and other implementation-detail-looking-things (auto trait impls, generic parameter variance, to some extent Drop and dropck, no doubt several other such things), I would strongly dislike this approach. This sort of analysis feels too fragile.

I think needing to remember the magic boilerplate in C++ to remove unwanted automatically-provided functions can be confusing. Sure, you only have to learn it once, but still.

Plus, imagine the safe constructor takes in an input which can only be constructed via unsafe. Could be arbitrarily complicated.

1 Like

See, the problem is, in the proposal as-is you give several examples of code that doesn't work right now, but you don't talk at all about what you want to have happen instead.

And that means I have no idea what "feature" you have in mind, so I have no way to assess whether it might be desirable or not?

6 Likes

(I just realise I'd overread what might be a typo ... assuming you meant coercing to a general UninhabitedErr<T,E> ...)

Are you talking about something like a less-naive version of this (playground):

#![allow(unused_variables)]
#![feature(never_type)]

/// A wrapper around a result which is always OK. We rely on X being a ZST for
/// various optimisations and functionality. (See SAFETY, below, for details on the
/// consequences.)
///
/// ## SAFETY
/// - X must be a zero-sized type. We have no way to ensure that the compiler
///   will validate this, so the constructor and .map_err() are both `unsafe`.  
pub struct Always<T, X>(Result<T, X>);

impl<T, X> Always<T, X> {
    /// ## SAFETY
    /// - X must be a zero-sized type. When calling `new` you must guarantee that
    ///   this is the case.
    pub unsafe fn new(t: T) -> Self {
        Self(Result::Ok(t))
    }

    pub fn map<F, U>(self, f: F) -> Always<U, X>
    where
        F: FnOnce(T) -> U,
    {
        Always(self.0.map(f))
    }

    /// Use map_err to change e.g. Always<String, !> to Always<String, Infallible>
    ///
    /// ## SAFETY
    /// - Z must be a zero-sized type. When calling `map_err` you must guarantee that
    ///   this is the case.
    pub unsafe fn map_err<Z>(self) -> Always<T, Z> {
        unsafe { Always(Ok(self.0.unwrap_unchecked())) }
    }
}

pub enum ZST {}

fn main() {
    let bang: Always<u32, !> = unsafe { Always::new(5) };
    let bang = bang.map(|x| x+1);
    let custom_zst: Always<u32, ZST> = unsafe { bang.map_err() };
}

If so ... I can see how any attempt to fake a map_err-like function would invalidate the safety of the type.

The best, safe, way to manage that kind of situation may well be to restrict the feature to only converting Try-types which have a suitable implementation of FromResidual. I can't see a way to implement Try and FromResidual safely for the above example (anyone?).

That would cover the "nested, no map / map is ugly" cases and leave the trait-bound case as "will be covered by the blanket impl From<!> for T at some point anyway, so don't try to overcomplicate things".

Yup, that’s a typo.

Your example is roughly what I’m thinking of (aside from shortening the fairly-arbitrary list of invariants).

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

Would it be better to make this less generic, and focus on providing mapping functions to individual types? How many std types and how many non-std types do you wish had more mapping functions?

You also dislike using |x| x to coerce !; perhaps defining the following function somewhere in each of your crates would suffice?

fn never<T>(never: !) -> T {
    never
}

Doing .map_ok(never) seems fairly clear to me. (Or .map_ok(coerce_never), etc.)

relying on Try doesn't add any real complexity IMO. It's the equivalent of .map(|x| try { x? }) rather than .map(|x| x). And feels safer to me right now (vs. simple coercion) as it requires the type author to have specifically approved the conversion.

Yes, inside my own codebase I can handle this fine but I'm loathe to include any Option<Result<!,E>> etc. in a public API. At that point I push the "pain" onto anyone downstream who now needs to add a map to almost every call. So I end up with Option<Result<(), E>> and a note in the docs that this should never be Some(Ok(_)) with no actual compiler guarantees on that (something else for me to think about during code-review, sad for never, and stops users being able to let(Err(e)) = ignore_blocking(e)?).

I don't see why any concrete type, whether ! or (), avoids the mapping problem. Is it that you'd normally want to map ! to () in your use cases?

Ah, and since Try is less magical than coercions, this looks like it can be implemented with a trait and blanket impl instead of compiler magic. Perhaps you could try making a (likely-nightly-only) crate with a map_try method?

I'd expect the mapping to be ! to T for an arbitrary T. () is only mentioned as current convention is generally to use () for situations where ! would be appropriate.

The problem with a trait is that the map_try would need to apply (iteratively) to the outer wrapper(s). impl<T,U> MapTry for T<U> where U: ... isn't valid.

As for why it avoids the mapping problem: The introduction of ! allows for this to happen. Right now moving from an Option<Result<(), E>> to an Option<Result<T, E>> requires you to define some default value to T via nested maps etc. It's clear why that must be the case for (), but ! is clear - this never occurs, so you're only ever going to get None or Some(Err(_))

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)

1 Like

The best way to avoid this error when using Infallible is match _b {} – you can match against all 0 variants of Infallible to avoid the error (which compiles because all 0 match arms produce a value of the correct type). It's possible that this technique isn't sufficiently widely-known, so maybe we'd benefit from Clippy or the like explaining it (but maybe that wouldn't be worth it, given that Infallible is due to be replaced by !).

I'm not sure what happens if you write the equivalent using !. In any case, it would make sense to not lint on a 0-variant match in unreachable code (because the construct in question explicitly serves as a proof that the code is unreachable / marks it as unreachable, so is unlikely to have been a mistake).

1 Like

directly matching ! doesn't trigger any lints on stable: Rust Playground

pub fn f() -> ! {
    panic!()
}

pub fn g() {
    match f() {}
}
1 Like

But doing it in unreachable code does. (I just fixed the link to the second playground in my post above and tried it.)

Yes, there are a few ways in stable to note that the final line is unreachable. In any case, changing to ! goes from "error if you don't" to "lint if you do", and therefore drastically changes expectations on how TryTypeFoo<!>? is handled.

(I wouldn't describe anything discussed here as a bug or error, just an ergonomics concern)

I've repeatedly found this rather annoying. I'd like to write some explicit syntax which proves "I have an impossible value here, discard this branch"; having that syntax itself be linted against defeats the purpose. I think ideally we should have something more explicit than match val {}; I could imagine a macro or std function such as provably_unreachable or exfalso or so. But the most important part is that we can write it without the lint getting in the way.

3 Likes

non_behavior!() /joking

Something like unreachable_checked!() which expands to:

#[deny(unfulfilled_lint_expectations)]
{
    #[expect(unreachable_code)]
    unreachable!()
}

??