Rust 1.100 Stabilizes the Never Type: Match Exhaustiveness, Generic Returns, and FFI Signatures

Rust 1.100 Stabilizes the Never Type: Match Exhaustiveness, Generic Returns, and FFI Signatures

pr0h0••25 min read•coding
AI Usage (84%)
On this page

What "Stabilized" Actually Means for Rust's Never Type

The never type has been the longest-running "almost done" feature in Rust — the sort of thing that keeps getting pushed out of release notes while a handful of people argue about inference fallback in a tracking issue. So when a headline says Rust 1.100 stabilizes !, the interesting question is not whether the compiler accepts the syntax, but which of your existing builds change shape. This post walks through what type-position ! actually affects — match exhaustiveness, generic return types like Result<T, !>, and -> ! in FFI signatures — and gives you a reproducible probe for each, so you can confirm the behavior on the toolchain in front of you instead of trusting a summary.

The Rust 1.100 headline claim and the one-line source it rests on

The claim going around is that Rust 1.100 stabilizes ! "after ten years of attempts." What I actually have is one aggregator item: a Foro3D headline dated 2026-11-13 with that wording, a one-line summary, and no body. That's it — no release notes, no RFC amendment, no stabilization report.

So I'll treat the headline as reported, not verified. What I can verify is the mechanical state of ! on stable Rust today, and that is the part you need to reason about a version bump. The version and date are at least internally consistent: Rust 1.85 shipped on 2025-02-20, releases land every 42 days, and fifteen releases later puts 1.100 in mid-November 2026 — within days of the reported publication date. That's a supporting check, not proof.

Why a bottom type is a type-system story, not a syntax story

! already exists in Rust source. You have written it. fn main() -> !, panic!(), loop {}, std::process::exit — all of that is stable, and has been since 1.0.

What's been gated for a decade is ! as a type you can name anywhere. That distinction is the whole argument. Once you can write Result<u8, !>, signatures say things they couldn't say before. Inference resolves unconstrained expressions differently. The optimizer gets a stronger promise at FFI boundaries. None of that is about panic! returning ! — that horse left the barn in 2015.

What this post will and will not assert as confirmed

What I'll assert plainly:

  • ! in return position works on stable, and I reproduced it.
  • ! in type position fails on stable with error[E0658], and I reproduced that too.
  • match x {} on Infallible compiles on stable today.

What I'll mark as inference:

  • the exact fallback policy 1.100 ships,
  • whether impl<T> From<!> for T is stable alongside !,
  • whether the rollout is edition-gated.

Those are precisely the three things a one-line headline cannot tell you, and they are the three that decide whether your build breaks.

The Never Type in Rust: Bottom Types, Coercion, and What Is Still Gated

Bottom types: a type with no values, a subtype of everything

! is the bottom type. It has zero inhabitants: there is no expression that produces a value of type ! and then continues. It is a subtype of every other type, which in Rust practice means it coerces to any T at a coercion site.

That coerce-to-anything property is why let x: u32 = return; typechecks. return has type !, ! coerces to u32, done.

Where ! already worked on stable Rust

All of this compiles with no feature gate:

use std::process;

fn fatal(msg: &str) -> ! {
    eprintln!("fatal: {msg}");
    process::exit(1)
}

fn retry_forever() -> ! {
    loop {
        // no break in this loop, so the block has type `!`
    }
}

fn classify(n: i32) -> &'static str {
    match n {
        0 => "zero",
        _ => fatal("unreachable in practice"),
    }
}

panic!, unreachable!, todo!, unimplemented!, process::exit, process::abort, and any loop without a reachable break all produce !. The -> ! return position has been stable the whole time.

What was actually gated behind #![feature(never_type)]

The gate is positional, not semantic. ! is accepted as a function return type; everywhere else it errors:

error[E0658]: the `!` type is experimental
 --> probe_type_position.rs:2:12
  |
2 |     let x: ! = panic!();
  |            ^
  |
  = note: see issue #35121 <https://github.com/rust-lang/rust/issues/35121> for more information
  = help: add `#![feature(never_type)]` to the crate attributes to enable

The same error fires for Result<u8, !>, Vec<!>, struct S { never: ! }, and impl Iterator<Item = !>. Exact span rendering moves between rustc versions; the error code and the help text are the stable part.

The practical consequence: on stable, ! is a thing functions return, not a type you can talk about. You cannot write a struct field, a generic argument, or a type alias in terms of it.

The distinction readers keep missing: return-position ! versus ! as a first-class type

Here's the sentence that clears up most of the confusion in never-type threads:

Return-position ! lets you describe divergence. Type-position ! lets you reason about divergence.

Describing is cheap. Rust could do it in 2015. Reasoning means the compiler has to decide what ! means in a position where inference has to pick a type — and that is where a decade went.

Why the Never Type Took Ten Years to Stabilize

RFC 1216 and the early consensus on the type itself

RFC 1216, "The ! type," proposed exactly what the headline describes: make ! a real type usable in type position, with the same coercion behavior the compiler already implemented for return position. The type theory was never the hard part. ! is a bottom type, bottom types coerce, done.

The RFC's own discussion is where you can watch the argument pivot from types to inference.

The hard part was never the type — it was inference fallback

Rust infers types. When an expression's type is unconstrained, the compiler applies fallback: a default. Integer literals fall back to i32. Unconstrained region and type variables fall back to 'static / () in various contexts.

Before type-position ! existed, ! could not be the type of a variable, so any unconstrained expression that produced ! had to fall back to something else. In practice: ().

fn main() {
    let x = panic!();   // what is `x`?
    // x is `()` on stable today
}

That fallback is load-bearing in real code. If x: (), then x has all of ()'s methods and trait impls. If x: !, method resolution changes. Suddenly impl Trait for () blocks no longer apply to x, and code that compiled for nine years stops compiling — not because you touched it, but because the compiler's default changed underneath you.

never_type_fallback and the ! versus () ambiguity in closures and Try/From resolution

The ambiguity shows up hardest in two places.

Closures. A closure body ending in panic!() or return has a return type the compiler must infer. If the closure is stored in a struct field or passed to a generic function, fallback kicks in.

Try and From. ? desugars through Try::branch and From::from. If the residual type of a diverging arm is ! rather than (), the From obligation changes shape, and you can get resolution differences or outright conflicts in code that never mentioned !.

That is why the feature sat behind never_type_fallback as a separate gate from never_type. You could opt into type-position ! without opting into the fallback change, and vice versa. The two have to stabilize together, because shipping the type without deciding fallback leaves the compiler in an inconsistent state.

Edition-gating as the escape hatch, and what that implies for code that stops compiling

Rust's precedent for exactly this kind of breakage is the edition mechanism: array IntoIterator in 2021, disjoint closure captures in 2021, gen as a keyword in 2024. Semantics change; the old edition keeps the old behavior; cargo fix --edition walks you across.

If the fallback changes are edition-gated, "my crate stops compiling on 1.100" really means "my crate stops compiling if I also change editions." Much softer landing, and it's the outcome I'd bet on — but I can't confirm it from a headline.

Marked-inference zone: what the public details do not yet pin down about the 1.100 rollout

Things I would want from an actual stabilization report and do not have:

QuestionWhy it mattersMy guess
Is fallback gated to a specific edition?Decides whether a plain toolchain bump breaks buildsLikely edition-gated
Is the From<!> blanket impl included?Decides whether ? works on Result<T, !>Unknown — the coherence overlap with From<T> for T is the sticking point
Does exhaustive_patterns ship too?Decides whether struct S(!) can be matched with zero armsLikely still separate
Does ! in a struct field make the struct uninhabited for exhaustiveness?Decides how useful typestate tricks areLikely requires exhaustive_patterns

Treat that table as a checklist to verify, not as facts.

Match Exhaustiveness After Never Type Stabilization

Baseline behavior today: diverging arms already satisfy exhaustiveness

match exhaustiveness has never cared that an arm diverges. An arm whose body has type ! coerces to the match's result type, and exhaustiveness is computed over patterns, not arm types.

enum State { Idle, Running, Halted }

fn next(s: State) -> State {
    match s {
        State::Idle => State::Running,
        State::Running => State::Halted,
        State::Halted => unreachable!(),   // `!` coerces to `State`
    }
}

This is stable, boring, and untouched by anything in this release. If your mental model of "never type stabilization" is "my unreachable!() arms will finally satisfy the exhaustiveness checker," that already works — this release does nothing for you.

The new cases: arms whose type is ! rather than a coerced ()

What changes is that you can now declare a match result type that contains !, instead of a real type that ! coerces into:

// Post-stabilization shape, illustrative
enum Step {
    Advance,
    Done,
}

fn run(state: State) -> Result<Step, !> {
    match state {
        State::Idle   => Ok(Step::Advance),
        State::Running => Ok(Step::Advance),
        State::Halted => Err(die("halted machine cannot advance")),
    }
}

fn die(reason: &str) -> ! {
    eprintln!("aborting: {reason}");
    std::process::abort()
}

On stable today you would write Result<Step, Infallible> and then need Err(e) => match e {} at every use site. Functionally identical, semantically noisier. The win is honesty in the signature, not new control flow.

Matching on uninhabited types — match infallible {} and Infallible without a dummy arm

Empty matches already work on stable for empty enums. This is the pattern everyone used before Result::unwrap_infallible existed:

use std::convert::Infallible;

fn unwrap_infallible<T>(r: Result<T, Infallible>) -> T {
    match r {
        Ok(v) => v,
        Err(e) => match e {},
    }
}

I reproduced this on stable with edition 2024, x86_64-unknown-linux-gnu. It compiles. The zero-arm match e {} is accepted because Infallible has no variants, so there is nothing to cover.

Once ! is a real type, ! gets the same treatment — match n {} where n: ! typechecks for the same reason. The edge case to watch is structs and enums that are uninhabited because a field is !:

struct Consumed(!);   // cannot be constructed, so the type is uninhabited

Whether match consumed {} compiles for Consumed depends on exhaustive_patterns, a separate unstable feature that teaches the exhaustive checker to look inside types. I would not assume it ships with never_type. Test it before designing around it.

Complete example: a three-state machine where one transition aborts

This is the version I would actually put in a codebase, because it exercises both the coercion path and the abort path in one function:

state-machine.rs
use std::convert::Infallible;
use std::process;

#[derive(Debug, Clone, Copy, PartialEq)]
enum State { Idle, Running, Halted }

enum Transition {
  To(State),
  Abort(&'static str),
}

fn apply(state: State, t: Transition) -> State {
  match t {
      Transition::To(next) => next,
      Transition::Abort(reason) => die(reason),
  }
}

// `!` in return position: stable since 1.0.
fn die(reason: &str) -> ! {
  eprintln!("aborting from {reason}");
  process::abort()
}

// The honest-signature version that needs type-position `!`.
fn plan(state: State) -> Result<State, !> {
  match state {
      State::Idle    => Ok(State::Running),
      State::Running => Ok(State::Halted),
      State::Halted  => Err(die("terminal state")),
  }
}

// What you write today on stable, for comparison.
fn plan_stable(state: State) -> Result<State, Infallible> {
  match state {
      State::Idle    => Ok(State::Running),
      State::Running => Ok(State::Halted),
      State::Halted  => Err(match die("terminal state") {}),
  }
}

fn main() {
  assert_eq!(apply(State::Idle, Transition::To(State::Running)), State::Running);
  assert_eq!(plan(State::Idle).ok(), Some(State::Running));
  assert_eq!(plan_stable(State::Running).ok(), Some(State::Halted));
}

apply compiles on stable. plan_stable compiles on stable. plan does not — it is the post-bump shape.

Reading the compiler error diff before and after the toolchain bump

Two probes, same file content, two toolchains:

// probe_type_position.rs — run with: rustc --edition 2024 probe_type_position.rs
fn main() {
    let x: ! = panic!();
    let _ = x;
}
$ rustc --edition 2024 probe_type_position.rs
error[E0658]: the `!` type is experimental
 --> probe_type_position.rs:2:12
  |
2 |     let x: ! = panic!();
  |            ^
  |
  = note: see issue #35121 for more information
  = help: add `#![feature(never_type)]` to the crate attributes to enable

error: aborting due to 1 previous error

That is the before column, reproduced on stable. The after column is what you should verify yourself in five minutes:

$ rustup toolchain install 1.100.0
$ rustc +1.100.0 --edition 2024 probe_type_position.rs && echo OK
## expected: OK
## unverified by me — this is the whole point of the check

The other diff worth capturing is the reverse error, where a function declares -> ! and the body does not diverge:

error[E0308]: mismatched types
 --> probe_bad_fallthrough.rs:1:13
  |
1 | fn f() -> ! {}
  |        ^^ expected `!`, found `()`
  |
  = note: this error originates in the function body

Wording varies by rustc version; the expected \!`, found `()`shape has been stable for years. After the bump, expect a second diagnostic category: code that used to infer()and now infers!. That error will not mention !` at all — it will be a trait-bound or method-not-found error pointing at a line you never edited.

Generics and Return Types After ! Stabilization

Result<T, !> and ControlFlow<!, T> as honest signatures

Result<T, !> says "this can only be Ok." ControlFlow<!, T> says "this never breaks." Both say more than Result<T, Infallible> and ControlFlow<Infallible, T> — and neither is expressible until ! has a name.

The honesty has a cost: downstream code now has to handle the impossible variant. Which brings us to the impl that decides whether this is pleasant or annoying.

The From<!> blanket impl and what ? does in a never-returning path

? on Result<T, E> requires E: Into<F> for the caller's error type F. For Result<T, !>, that means you need impl<T> From<!> for T.

That impl has a coherence problem: it overlaps with the reflexive impl<T> From<T> for T at T = !. As far as I know, that overlap is the main reason the blanket conversion has stayed gated for so long. Whether 1.100 ships it stable is the single highest-value thing to check, because it decides whether the next snippet works:

fn forwarded() -> Result<u8, String> {
    let v: Result<u8, !> = Ok(7);
    let n = v?;          // needs `From<!> for String`
    Ok(n)
}

If From<!> is not stable, write it explicitly:

fn forwarded_manual() -> Result<u8, String> {
    let v: Result<u8, !> = Ok(7);
    match v {
        Ok(n) => Ok(n),
        Err(never) => match never {},   // still an empty match, still fine
    }
}

Annoying, correct, and independent of any stabilization.

impl Iterator<Item = !> and impl Future<Output = !> — useful or a footgun

impl Iterator<Item = !> is an iterator that can only ever yield None. next() returns Option<!>, whose only inhabitant is None. The type is shorthand for "always empty." It typechecks, and it is almost never what you want — std::iter::empty() is generic and already says this.

impl Future<Output = !> is the opposite: a future that never resolves. That one is genuinely useful. std::future::pending::<T>() is already generic, so

let never: std::future::pending::<!> = std::future::pending();

becomes expressible, and in a select!-style construct it can encode "this branch cannot complete" at the type level instead of in a comment. That is a real expressiveness gain, and it is the one place in async code where I expect ! to earn its keep immediately.

The inference hazard: generic parameter defaulting to ! instead of ()

Consider a generic function called only from a diverging path:

fn collect_all<T, I: IntoIterator<Item = T>>(i: I) -> Vec<T> {
    i.into_iter().collect()
}

fn run(flag: bool) -> Vec<u8> {
    if flag {
        return collect_all(Vec::<u8>::new());
    }
    // ...
    Vec::new()
}

Nothing here is ambiguous. Now imagine a call site where the argument is a diverging expression. T has no constraint. Before type-position !, fallback made it (). After, it could be !. Now Vec<T> is Vec<!>, and every subsequent .push() in that function needs a ! value. The error will point at the push, not at the call site that caused it.

This is the whole breakage class in one example. It is also why I would not adopt ! in a large codebase on day one of a release.

A reproducible check: one crate, one cargo +stable versus cargo +1.100 comparison, output shown side by side

The cheapest way to find out what ! does to your code is to not write ! at all and just bump the compiler. If fallback changed, the diff appears without you adding a single annotation.

## pin the old toolchain in rust-toolchain.toml first
cargo +stable build --all-targets --all-features 2>&1 | tail -40
cargo +1.100.0 build --all-targets --all-features 2>&1 | tail -40
probe                              | stable            | 1.100.0 (expected)
-----------------------------------+-------------------+-------------------
let x: ! = panic!();               | error[E0658]      | compiles
Result<u8, !> in a signature       | error[E0658]      | compiles
match infallible {}                | compiles          | compiles
impl<T> From<!> for T availability | gated             | unknown
let x = panic!() fallback type     | ()                | likely ! or gated

The first two rows I checked. The last two are the ones you have to fill in yourself, and they decide your migration plan.

FFI Signatures: -> ! for C Functions That Never Return

This is where I think ! matters most and where a wrong annotation is most expensive.

C functions that never return: abort, exit, _exit, pthread_exit

C has had a way to say "does not return" for a long time: _Noreturn in C11, __attribute__((noreturn)) in GCC/Clang, [[noreturn]] in C++11. The C standard is explicit that if a function declared _Noreturn does return, the behavior is undefined.

Rust's -> ! is the same promise. The libc crate already declares several of these with -> ! rather than -> (), which is the right call — the compiler can delete the code after the call.

Writing the binding: extern "C" { fn abort() -> !; } and what the optimizer may now assume

use std::ffi::c_int;

// edition 2024 requires `unsafe extern`
unsafe extern "C" {
    fn abort() -> !;
    fn exit(status: c_int) -> !;
    fn _exit(status: c_int) -> !;
}

What the optimizer does with that !:

  • treats the call as a terminator, not a call with a result,
  • emits no code after it in that basic block,
  • may drop the surrounding function's epilogue entirely,
  • may propagate "this path is unreachable" upward into callers and into generic instantiations.

Before type-position !, the same binding written as fn abort(); still terminated control flow in practice — you just had to add unreachable!() or return after it, and the compiler had to keep a landing pad that could never be reached.

The export side: #[no_mangle] pub extern "C" fn ... -> !

Going the other direction:

// edition 2024 style attributes
#[unsafe(no_mangle)]
pub extern "C" fn hackyjs_trap() -> ! {
    std::process::abort()
}

This is a promise made to C callers. Clang and GCC will honor it and remove the return path in the caller. If you ever change the body so it can return, you have introduced UB in every C caller compiled against the header that says _Noreturn.

Also note the unwind behavior: an unwind out of a plain extern "C" function aborts rather than propagating. If the C side genuinely unwinds, the signature you want is extern "C-unwind", and -> ! is then a lie in a different way.

Safety limits — -> ! is a promise, not a proof; longjmp and unwinding across the boundary

Three failure modes worth knowing by name:

Wrong annotation on a returning function. The compiler removes the return path. Whatever the function does on return — falls into the next function, hits an unreachable instruction — is UB, not a recoverable error.

longjmp through a Rust frame. setjmp/longjmp skips destructors. If a -> ! function can escape via longjmp back into a caller's frame, the type is a lie about control flow even though nothing "returns" in the normal sense.

Signal handlers that return. A handler installed for a signal is supposed to return normally. Declaring it -> ! because it "usually doesn't" is exactly the kind of annotation that survives testing and fails in production.

⚠️

A wrong -> ! is worse than no annotation. Without it the compiler keeps a return path you never take; with it, the compiler deletes that path and every caller inherits undefined behavior. Annotate only what the C declaration already guarantees with _Noreturn or noreturn, and re-check that guarantee on every platform you ship.

Migration and Breakage Surface When You Bump to Rust 1.100

What breaks: fallback-dependent code, not !-annotated code

Nobody has working !-annotated code to break, because ! in type position didn't compile on stable. So any breakage from this release lands in code that never mentioned !:

  • unconstrained bindings that previously fell back to (),
  • closures whose return type was inferred from a diverging body,
  • generic parameters resolved through Try/From where the residual type changes,
  • method calls on values whose type silently shifted from () to !.

That list is short and unpleasant, and it is why I want the fallback change edition-gated.

Order of operations for a toolchain bump: pin, build, read the diff, then adopt

## 1. pin so the bump is explicit and revertible
printf '[toolchain]\nchannel = "1.99.0"\n' > rust-toolchain.toml

## 2. record a clean baseline on the old toolchain
cargo build --all-targets --all-features 2>&1 | tee /tmp/before.log
cargo test --all-features 2>&1 | tee -a /tmp/before.log

## 3. bump and rebuild
sed -i 's/1.99.0/1.100.0/' rust-toolchain.toml
cargo build --all-targets --all-features 2>&1 | tee /tmp/after.log

## 4. read only the delta
diff <(grep -E '^(error|warning)' /tmp/before.log) \
     <(grep -E '^(error|warning)' /tmp/after.log)

The diff step is the actual work. If it is empty, adopt. If it is not, the new errors name the call sites — and the fix is usually a turbofish or an explicit annotation, not a redesign.

Lints and tooling to watch (clippy, rust-analyzer inference, edition migration)

  • cargo clippy will likely gain a lint for Result<T, Infallible>-style signatures that could be Result<T, !>; do not let it churn your API surface in the same PR as the toolchain bump.
  • rust-analyzer inference hints will change in diverging closures the moment fallback changes. Expect inlay hints to move before any error appears — that is your early warning.
  • cargo fix --edition is the mechanism if fallback is edition-gated. Run it in its own commit so the mechanical rewrite is separable from your own edits.
  • cargo report future-incompatibilities --id <id> remains the right way to read deprecation reports that get truncated in normal output.

Table: change, symptom, fix

ChangeSymptomFix
Fallback resolves to ! where () was expectederror[E0599]: no method named ... found for type \!`` on a line you did not editAnnotate the binding: let x: () = panic!();
Generic parameter inferred as !Vec<!> used where Vec<u8> was intended; push failsTurbofish at the call site: collect_all::<u8, _>(...)
! now nameable but From<!> still gated? on Result<T, !> fails to satisfy Into<F>Match explicitly: Err(never) => match never {}
Struct with a ! field is uninhabited but not matchablematch consumed {} demands an armWait for exhaustive_patterns, or keep the sentinel pattern
Wrong FFI -> !No error; UB in optimized buildsAudit every -> ! against the C _Noreturn declaration

What I Would Test Before Adopting ! in a Real Codebase

A five-check adoption list with the command for each

  1. Confirm the release actually shipped. rustup toolchain install 1.100.0 && rustc +1.100.0 --version — cross-check the date against releases.rs before trusting any blog post, including this one.
  2. Probe type position. rustc +1.100.0 --edition 2024 probe_type_position.rs && echo OK where the probe is let x: ! = panic!();.
  3. Probe From<!>. Compile fn f() -> Result<u8, String> { let v: Result<u8, !> = Ok(1); Ok(v?) } and see whether it resolves without extra bounds.
  4. Build everything, twice. cargo +stable build --all-targets --all-features then cargo +1.100.0 build --all-targets --all-features, diffing the error sets. Include benches and examples — inference surprises hide there.
  5. Audit FFI annotations. grep -rn -- '-> !' src/ | grep -v '^src/.*\.md' and check each one against the C header, platform by platform.

Where ! earns its keep in an existing codebase versus where it adds noise

Earns its keep:

  • FFI bindings for abort, exit, _exit, pthread_exit, and anything your own C headers mark noreturn.
  • Fallible signatures that cannot fail: Result<T, !>, ControlFlow<!, T>.
  • Never-resolving futures in select!-style code.
  • Typestate markers that must be unconstructible: struct Consumed(!) — the field type is the invariant.

Adds noise:

  • impl Iterator<Item = !> — std::iter::empty() already says it, generically.
  • Rewriting existing unreachable!() arms to -> ! functions. Same behavior, more indirection.
  • Any place where Infallible is already in the public API. Changing Infallible to ! is a breaking change for your downstream users and buys nothing at the type level.

Confirmed Versus Inferred: What the Available Sources Support

Table separating what the source states from what I am extrapolating

ClaimStatusBasis
Rust 1.100 stabilizes the never typeReportedSingle Foro3D headline, 2026-11-13; not verified against official release notes
The work took roughly ten yearsReportedSame headline; consistent with RFC 1216 (2015)
! in return position is stable todayConfirmedReproduced with rustc on stable
! in type position is gated todayConfirmedReproduced: error[E0658], issue #35121
match e {} on Infallible compiles todayConfirmedReproduced on stable, edition 2024
1.100 lands around 2026-11-12Inferred42-day cadence from 1.85 (2025-02-20)
Fallback changes are edition-gatedInferredFollows the 2021/2024 precedent; unverified
From<!> blanket impl ships stableUnknownCoherence overlap with From<T> for T is the open question
exhaustive_patterns ships with itUnlikelySeparate feature with its own tracking; no indication in the source

Open questions: release date accuracy, MSRV policy, edition requirements

  • Release date. The aggregator timestamp is not the release timestamp. Check releases.rs or the official blog.
  • MSRV policy. Whatever 1.100 raises your MSRV to, remember that it propagates to every downstream consumer of every crate you publish. A library adopting type-position ! is a library with an MSRV floor of 1.100.
  • Edition requirements. If fallback is edition-gated, edition = "2024" crates and edition = "2027" crates may behave differently on the same compiler. That is fine, but it makes "it compiles on my machine" a weaker statement than usual.

Further Reading

Primary sources — RFC 1216, the never-type tracking issue, official Rust release notes

What to read instead of secondhand summaries

The compiler is the primary source. rustc +1.100.0 --edition 2024 probe.rs answers the three questions I could not answer above faster than any announcement will. When you do want prose, prefer the stabilization report linked from the release notes and the tracking-issue comments where the fallback decision was actually made — those contain the reasoning, and the reasoning is what tells you whether your code is in the blast radius.

Conclusion: A Small Type With a Large Blast Radius for Rust Code

The practical takeaway for code that already diverges

If your code already uses -> ! on stable, you get very little from this release. Your unreachable!() arms already satisfy exhaustiveness. Your process::exit already terminates control flow. Nothing on your match statements changes.

What you get is the ability to stop lying in signatures: Result<T, !> instead of Result<T, Infallible>, ControlFlow<!, T> instead of a sentinel, unconstructible typestate markers. Real, but modest, and not worth a rushed adoption. What you lose, potentially, is inference stability — and that cost lands on code you never touched, which is why the pin-build-diff sequence matters more here than for a typical point release.

My position: adopt the compiler, adopt ! in signatures only where the impossible case is genuinely impossible, and do not rewrite working Infallible APIs.

The takeaway for anyone maintaining FFI bindings

This is the one place I would move deliberately rather than waiting. fn abort() -> ! instead of fn abort() removes dead landing pads from every caller, and if the C side already declares _Noreturn, the annotation adds no risk — it just tells the optimizer something it was not allowed to assume before.

The converse is the part to be disciplined about. Every -> ! in an extern block is a promise the optimizer will enforce by deleting code. longjmp, signal handlers, and platform-specific shims that "never return in practice" are exactly the cases where the annotation is technically wrong and catastrophically wrong at -O2. Audit each one against the header, and if you cannot find the _Noreturn, leave the annotation off.

Share this post

More posts

Loading comments…