Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Propagating Errors with Result and the Question-Mark Operator

Welcome back. Last time, you modeled the player using structs for facts that coexist and enums for mutually exclusive playback states. You also used exhaustive match expressions to make every state explicit.

Now we add a second kind of outcome to player operations: success or recoverable failure. Loading a manifest may fail because a path is missing; a user may choose an unsupported source; a requested volume may be invalid. These should not normally crash the program. Rust represents such outcomes with Result, and the question-mark operator, ?, makes it practical to pass failures to the layer that can make the right decision.

This pattern will remain central when the player becomes asynchronous. An async network operation will eventually produce a Result; after awaiting it, you will use the same error-propagation reasoning introduced here.


Result: success and failure are both explicit values

Rust does not use exceptions for ordinary recoverable errors. Instead, a fallible operation returns a value that records which outcome occurred:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

This is a useful mental model of the standard-library type:

  • T is the successful value’s type.
  • E is the error value’s type.
  • Ok(value) means the operation succeeded.
  • Err(error) means it failed in a known, recoverable way.

For a media-player operation, a return type might be:

Result<Track, PlayerError>

It promises one of two outcomes: a usable Track, or a PlayerError explaining why no track was produced.

A successful operation that has no meaningful value to return uses the unit type, ():

Result<(), PlayerError>

For example, setting the volume either succeeds with “nothing further to report” or fails with an explanatory error.

#[derive(Debug)]
struct PlayerState {
    volume: u8,
}

#[derive(Debug)]
enum PlayerError {
    InvalidVolume(u8),
}

fn set_volume(
    player: &mut PlayerState,
    volume: u8,
) -> Result<(), PlayerError> {
    if volume > 100 {
        return Err(PlayerError::InvalidVolume(volume));
    }

    player.volume = volume;
    Ok(())
}

Notice two important details:

  1. The invalid value is preserved inside PlayerError::InvalidVolume. The caller can inspect, log, or display it.
  2. Mutation happens only after validation. If volume is invalid, player.volume remains unchanged.

This is a different category from an absent but non-erroneous value. For example, a player with no selected subtitle track might reasonably use Option<SubtitleTrack>. An unreadable manifest, malformed user input, or unavailable resource is an operation that failed, so it calls for Result.

Rust marks Result with #[must_use]. If you call a fallible function and discard its result, the compiler warns you. That warning is valuable: ignoring a failed write, request, or state update is rarely correct in a player.

Error Handling in Rust

Watch the selected portions of “Error Handling in Rust” by Let’s Get Rusty for a compact visual introduction to Result, then error propagation with ?.

First watch Result basics to reinforce the distinction between a successful payload and an error payload. Then watch propagation, which rewrites explicit match-based propagation using ?. Finish with main returns Result for the special case of using ? in a program entry point.


Handle an error where you have enough context

Because Result is an enum, the match skills from the previous lesson apply directly. Suppose we validate a manifest-like source before beginning playback:

#[derive(Debug)]
struct Track {
    title: String,
    duration_ms: u64,
}

#[derive(Debug)]
enum PlayerError {
    EmptySource,
    UnsupportedFormat {
        source: String,
    },
    InvalidVolume(u8),
}

fn select_track_from_source(source: &str) -> Result<Track, PlayerError> {
    if source.is_empty() {
        return Err(PlayerError::EmptySource);
    }

    if !source.ends_with(".mpd") {
        return Err(PlayerError::UnsupportedFormat {
            source: source.to_owned(),
        });
    }

    Ok(Track {
        title: String::from("Opening Scene"),
        duration_ms: 240_000,
    })
}

A caller can explicitly decide what each outcome means in its own context:

fn start_from_source(source: &str) {
    match select_track_from_source(source) {
        Ok(track) => {
            println!("Ready to play: {}", track.title);
        }

        Err(PlayerError::EmptySource) => {
            eprintln!("Choose a media source first.");
        }

        Err(PlayerError::UnsupportedFormat { source }) => {
            eprintln!("Unsupported source format: {source}");
        }

        Err(PlayerError::InvalidVolume(_)) => {
            unreachable!("this operation does not set the volume");
        }
    }
}

This match is exhaustive because PlayerError is an enum. The final arm exists because the enum contains InvalidVolume, even though this particular call cannot produce it.

In production code, that is a cue to consider API design:

  • Perhaps start_from_source should work with a smaller, dedicated source-loading error type.
  • Perhaps the application boundary should display a generic message and log the full error.
  • Perhaps different error kinds should trigger different recovery actions, such as asking for another source or retrying a network request.

The key point is that select_track_from_source does not choose the user-interface policy itself. It reports what happened. The caller, which knows whether this is a command-line tool, a graphical player, or a background preload task, chooses the response.

panic!, unwrap, and expect are different policies

This code compiles:

let track = select_track_from_source("movie.mpd").unwrap();

But unwrap() means:

  • extract the value if the result is Ok;
  • panic if it is Err.

That is not error propagation. It turns a recoverable input failure into a process crash.

expect is similar, but supplies a better panic message:

let track = select_track_from_source("bundled-demo.mpd")
    .expect("the bundled demonstration manifest must be valid");

expect can be reasonable in tests, examples, prototypes, or when a failure would demonstrate a genuine programming or packaging mistake. For normal player inputs, filesystem access, parsing, and networking, prefer Result and a deliberate recovery policy.


Propagation: let the caller decide

A helper often lacks enough information to resolve an error sensibly. It may not know whether the application should retry, show a dialog, select a fallback track, or stop playback. In that case, it should return the error unchanged.

Here is a player setup operation written with explicit match expressions:

fn prepare_player_verbose(
    player: &mut PlayerState,
    source: &str,
    volume: u8,
) -> Result<Track, PlayerError> {
    let track = match select_track_from_source(source) {
        Ok(track) => track,
        Err(error) => return Err(error),
    };

    match set_volume(player, volume) {
        Ok(()) => {}
        Err(error) => return Err(error),
    }

    Ok(track)
}

Read the first match carefully:

  • On Ok(track), it extracts the track and keeps executing.
  • On Err(error), return Err(error) exits the whole function immediately.
  • Only if track selection succeeds does the function try to set the volume.
  • If both operations succeed, the function returns Ok(track).

The function composes two fallible operations without deciding how to recover. That decision remains available to its caller.

The ordering also affects observable behavior. Because the track source is validated before volume is changed, an invalid source leaves the player state untouched. For stateful operations, consider the order of validation and mutations deliberately: an error can occur after some earlier work has already succeeded.

Recoverable Errors with Result

Read the Rust Book’s explanation of error propagation before relying on the shorthand. It builds the central idea with explicit match expressions, then shows precisely what the question-mark operator removes.

In the section “Propagating Errors,” read from the paragraph beginning “This function can be written in a much shorter way” through the introduction to “The ? Operator Shortcut.” Focus on the manual walkthrough: identify the early return Err(e) path for each fallible call. Then read the complete subsection “The ? Operator Shortcut,” including the discussion of error conversion. Use the rewrite to compare the verbose and concise forms line by line.


The ? operator is early return written concisely

The question-mark operator is Rust’s standard shorthand for the propagation pattern above:

fn prepare_player(
    player: &mut PlayerState,
    source: &str,
    volume: u8,
) -> Result<Track, PlayerError> {
    let track = select_track_from_source(source)?;
    set_volume(player, volume)?;

    Ok(track)
}

At the first ?, Rust behaves essentially as though you wrote:

let track = match select_track_from_source(source) {
    Ok(track) => track,
    Err(error) => return Err(error),
};

So ? has two possible behaviors:

Result received Meaning of result?
Ok(value) Produces value and execution continues
Err(error) Returns early from the enclosing function with that error

It does not log the error, retry the operation, convert it into a default value, or panic. It only transfers the recovery decision upward.

That distinction matters when reading compact Rust:

let track = select_track_from_source(source)?;
set_volume(player, volume)?;

Reaching set_volume proves that selecting a track succeeded. Reaching Ok(track) proves that both operations succeeded.

The enclosing function must be able to return the failure

Because ? may return early with an error, it cannot be used in an ordinary function returning ():

fn invalid_example() {
    let track = select_track_from_source("movie.mpd")?;
}

The function has nowhere compatible to return the Err, so Rust rejects this code.

Give the function a compatible result type instead:

fn valid_example() -> Result<(), PlayerError> {
    let track = select_track_from_source("movie.mpd")?;
    println!("Selected: {}", track.title);

    Ok(())
}

The final Ok(()) means: “this function completed successfully, and its successful output is the unit value.”

main may also return a Result:

fn main() -> Result<(), PlayerError> {
    let mut player = PlayerState {
        volume: 70,
    };

    let track = prepare_player(&mut player, "movie.mpd", 80)?;

    println!("Playing: {}", track.title);
    Ok(())
}

For a small command-line program, this is a natural boundary: success reaches Ok(()); a propagated failure becomes the program’s failure result. Larger applications often choose to match at an outer boundary in order to present user-facing messages, log diagnostics, or trigger fallback behavior.


Error types and conversion

So far, every fallible helper returns the same error type, PlayerError. That makes propagation direct.

Real applications often call libraries that use their own error types. For example, std::fs::read_to_string returns an io::Error on failure. A player-specific API may want to retain both the operation context and the underlying I/O error:

use std::fs;
use std::io;

#[derive(Debug)]
enum PlayerError {
    ManifestRead {
        path: String,
        source: io::Error,
    },
}

fn read_manifest(path: &str) -> Result<String, PlayerError> {
    fs::read_to_string(path).map_err(|source| {
        PlayerError::ManifestRead {
            path: path.to_owned(),
            source,
        }
    })
}

map_err transforms only the error half of a Result. If the file read succeeds, the String is preserved in Ok. If it fails, the closure builds a more domain-specific PlayerError that remembers which manifest path failed.

The ? operator can also convert error types automatically when the enclosing error type implements the appropriate From conversion. Conceptually, if a called function returns Result<T, E1> and your enclosing function returns Result<U, E2>, ? can propagate the failure when Rust knows how to build E2 from E1.

For now, the practical rule is:

  • Use one error type across a small operation when possible.
  • Use map_err when you need to add useful context or deliberately change the error type.
  • Later, traits will let you define reusable conversions between error types.

Do not erase errors casually. A media player may need the distinction between “manifest not found,” “permission denied,” “malformed metadata,” and “network deadline exceeded” to choose an appropriate response.


Connection to future async code

The control-flow principle does not change in asynchronous Rust. Eventually, an async fetcher will commonly have a signature like:

async fn fetch_manifest(
    url: &str,
) -> Result<String, NetworkError> {
    // ...
}

Calling it produces a future, so the eventual composition often reads:

let manifest = fetch_manifest(url).await?;

For now, treat .await and ? as separate responsibilities:

  • .await waits for the async operation’s future to resolve.
  • ? handles the resulting Result by extracting success or propagating failure.

The next modules will explain the suspension and wakeup mechanics behind .await. The error path itself will still follow the Result model established here.


Key takeaways

  • Result<T, E> represents either successful completion with Ok(T) or recoverable failure with Err(E).
  • Choose Result for operations that can fail; use Option when a value is simply present or absent.
  • Use match when the current layer needs distinct recovery actions for different errors.
  • A function can propagate a failure by returning Err(error) to its caller.
  • ? is concise early-return control flow: it extracts an Ok value or returns an Err from the enclosing function.
  • A function using ? must return a compatible Result type.
  • Avoid unwrap() on normal player inputs and operational failures because it panics rather than recovering.
  • Preserve helpful context when translating lower-level errors into player-domain errors.

Next, you will make reusable player components with traits and generic bounds. Those tools will later support flexible abstractions for sources, parsers, transports, and error conversions without giving up Rust’s static guarantees.

Can't find a good explanation? Sign up and we'll make it for you

Sign up