Recommended resources
The freely available resources this lesson will direct you to.
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:
Tis the successful value’s type.Eis 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:
- The invalid value is preserved inside
PlayerError::InvalidVolume. The caller can inspect, log, or display it. - Mutation happens only after validation. If
volumeis invalid,player.volumeremains 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.
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_sourceshould 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_errwhen 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:
.awaitwaits for the async operation’s future to resolve.?handles the resultingResultby 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 withOk(T)or recoverable failure withErr(E).- Choose
Resultfor operations that can fail; useOptionwhen a value is simply present or absent. - Use
matchwhen 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 anOkvalue or returns anErrfrom the enclosing function.- A function using
?must return a compatibleResulttype. - 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.