Skip to content
← Back to explainers

Rust explainer

#[non_exhaustive]: designing enums you can extend later

About 5 min to read. Allow extra time to work through examples.

Use non-exhaustive types to leave room for new cases, and choose a useful fallback when consuming them from another crate.

This article uses Rust. You can make it your practice mode to browse related content across the site.

An exhaustive match makes the compiler check that you handled every enum variant. That is useful when you want a new variant to trigger a review of existing code. A public library sometimes needs a different contract: callers should handle selected cases and provide a fallback for future ones.

#[non_exhaustive] expresses that choice at a crate boundary. It can mark an enum, a struct or an individual enum variant. Its restrictions apply to code outside the crate that defines the marked item.

See the enum rule with two crates

Save this library as errors.rs:

#[non_exhaustive]
#[derive(Debug)]
pub enum ConnectError {
    Timeout,
    Refused,
}

pub fn local_label(error: &ConnectError) -> &'static str {
    match error {
        ConnectError::Timeout => "timeout",
        ConnectError::Refused => "refused",
    }
}

local_label is in the defining crate, so its match can list all current variants without a fallback. Now save a separate consumer as main.rs:

use errors::ConnectError;

fn label(error: &ConnectError) -> &'static str {
    match error {
        ConnectError::Timeout => "timeout",
        ConnectError::Refused => "refused",
        _ => "another connection error",
    }
}

fn main() {
    assert_eq!(label(&ConnectError::Timeout), "timeout");
    assert_eq!(label(&ConnectError::Refused), "refused");
}

Build the library, then its consumer:

rustc --edition=2024 --crate-type=lib --crate-name errors errors.rs
rustc --edition=2024 main.rs --extern errors=liberrors.rlib

Run the resulting executable. Then remove the _ arm from the consumer and compile it again. It fails with E0004 even though you listed both current variants. The library's own match still compiles without that arm.

A nested mod would not create this distinction: modules in one crate share the defining crate's access to the marked type.

What compatibility does this buy?

Without the attribute, adding a public enum variant can break downstream exhaustive matches. With the attribute already present, callers must account for additional variants from the start. Adding another ordinary variant can then preserve those matches without a breaking release.

That is not a promise that every possible addition is compatible. A variant carrying a new field type can affect traits such as Send or Sync, and a new error case can require different application behavior. Review the whole API change. Adding #[non_exhaustive] to an existing exhaustive public enum is itself breaking for callers that relied on exhaustive matching.

Cargo's SemVer guidance explains the relevant compatibility categories. Build compatibility and useful handling of a new case are separate questions.

Check your understanding What should a fallback arm do? Show answer

Choose behavior that remains reasonable for an unfamiliar case: return a general error, propagate the original error, or record it for diagnosis. A binding such as other => report(other) preserves access to the value. _ => todo!() compiles, but panics if that arm runs.

Do not assume every unknown connection error should be retried or ignored. The right response depends on the operation and the caller's policy.

You can observe this with the standard library

std::io::ErrorKind is defined in another crate and is non-exhaustive. This complete one-file program handles one case and falls back for the others:

use std::io::ErrorKind;

fn message(kind: ErrorKind) -> &'static str {
    match kind {
        ErrorKind::NotFound => "The requested item was not found.",
        _ => "An I/O operation failed.",
    }
}

fn main() {
    assert_eq!(message(ErrorKind::NotFound), "The requested item was not found.");
    assert_eq!(message(ErrorKind::PermissionDenied), "An I/O operation failed.");
}

Even listing every currently documented ErrorKind variant would not remove the need for a fallback. This lesson uses an article so you can compare the library and consumer sides explicitly, rather than merely add a wildcard to an existing standard-library match.

Structs and individual variants

For a non-exhaustive struct defined in another crate, callers cannot use a struct literal to construct it. For a named-field struct pattern, they must include .. to allow additional fields. The library should provide a supported way to obtain a value, such as a constructor, a builder or a Default implementation. It can still construct the struct normally inside its own crate.

Marking an enum and marking one of its variants are different decisions. An enum-level attribute leaves room for new variants. Marking a named-field variant leaves room for more fields in that variant and restricts downstream construction and matching. Enum-level non-exhaustiveness alone does not prevent callers from constructing existing public variants, as the consumer example shows.

Tuple and unit forms have additional constructor and pattern restrictions; consult the Rust Reference when designing those APIs.

Choose the attribute for a reason

Use it when extending a public type is part of your compatibility plan and callers can handle unknown cases meaningfully. Error enums and configuration structs are common candidates, but they are not automatically good choices.

Leave an enum exhaustive when handling every variant is a useful obligation. For types used only inside their defining crate, this attribute does not relax local exhaustiveness checks. An application can contain several crates, so decide based on the actual boundary, not whether the project is called a library.

Older APIs sometimes used a hidden placeholder variant or a private dummy field to restrict callers. Those are real parts of the type: a public hidden variant can still be constructed, and private fields affect construction and patterns. The attribute states the extension policy directly without adding that placeholder data. Migrating an existing API still needs compatibility review.

step 24 of 26

Shape Your Data: Structs, Enums, Pattern Matching

Up next Protect rectangle invariants with private fields