Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

💻Virtual Lab: Result & Option

This virtual lab explores how Rust models the idea that an operation might not produce the value you asked for. Other languages tend to bundle every kind of “things went wrong” into one mechanism (a thrown exception, or a sentinel like null or -1). Rust splits the idea cleanly into two types and makes both visible in the function signature, so a caller can see exactly how a call can fail and the compiler will not let any failure be ignored silently.

The two types are:

enum Option<T> { Some(T), None } // a value, or nothing
enum Result<T, E> { Ok(T), Err(E) } // a value, or a failure with a reason

The first, Option<T> represents absence: there is either a value (Some) or there is not (None), and no reason is attached, because absence needs no explanation. The second, Result<T, E> represents failure with a reason: either success (Ok) or a failure that carries an error value (Err) explaining what went wrong.

Crucially, neither of these is an exception. They are ordinary values that travel through your code, and you decide at each point what to do with them. This lab lets you drive a small two-failure pipeline end to end and watch those values flow.

The pipeline you will configure is a single function:

fn run(s: &str) -> Result<i32, &'static str> {
let n = s.parse::<i32>()<strategy 1>; // parse can fail: Result
let q = 100i32.checked_div(n)<strategy 2>; // division can be impossible: Option
Ok(q)
}

Two things can go wrong. The string parse can fail (a Result, because “not a number” is a reason), and the division can be impossible when n is zero (an Option, because there simply is no quotient). You choose a handling strategy at each failure point and watch the consequences.

If any of the words used in this lab feel hazy, here is a short reference. Skim it now, come back as needed.

Show/Hide Glossary

TermMeaning
Option<T>An enum with two variants: Some(value) when a value is present, None when it is absent. Absence carries no reason.
Result<T, E>An enum with two variants: Ok(value) on success, Err(error) on failure. The Err carries a value of type E explaining the failure.
Some / NoneThe two variants of Option. None is absence, not an error: it only becomes an error if your code insists on a value it does not have.
Ok / ErrThe two variants of Result. Err is a failure that travels as a value, not a thrown exception.
.unwrap()Take the inner value if present (Ok/Some), otherwise panic and crash the thread. A promise that the failure case cannot happen.
? operatorIf the value is Ok/Some, unwrap it and carry on; if it is Err/None, return early from the enclosing function, handing the failure back to the caller. Keeps the happy path readable.
.unwrap_or(default)Take the inner value if present, otherwise substitute a supplied default. The failure is absorbed silently.
.checked_div(n)Integer division that returns Option<i32>: Some(quotient) normally, None when n is zero. No panic, no undefined behaviour.
PanicA controlled crash of the current thread, printing a message. Reserved for situations a correct program should never reach.
Early returnLeaving a function before its end, here by handing an Err back to the caller. The failure becomes the caller’s problem, with no crash.
Show/Hide Instructions

Below this guide is the interactive lab itself. The interface is arranged vertically:

  1. The pipeline panel at the top shows the run function as you have currently configured it, with your chosen strategies spliced in. This is real, valid Rust that reflects exactly what the simulation will do.
  2. The configuration panel has three rows of choices. Input picks the string handed to run ("4", "0", or "abc"). On Err picks how the parse failure is handled. On None picks how an impossible division is handled. Hover any chip to see a one-line description of what it does.
  3. The pipeline run panel is where you watch the value travel. Step reveals one node at a time; Run all reveals the whole pipeline at once; Reset clears it back to the start. The counter (e.g. 3 / 9) tells you how far through the run you are.
  4. The C++ mirror shows the same input meeting std::stoi and integer division, to highlight where the two languages part company.
  5. The challenges panel asks you to predict the outcome of three configurations. Your first answer is the one that counts.
  6. The discussion panel at the bottom summarises the idea.

Watch the colours, because they carry the teaching:

  • Green is success: Ok and Some, a value that made it through.
  • Rose is an error condition: Err, and any panic.
  • Amber is None and graceful early returns. Amber is deliberately not rose, because absence is not an error in itself; it only becomes a problem if the code demands a value that is not there.

Before you tackle the exercises, take a quick walk through the lab so you know what every part of the picture means.

Show/Hide Instructions

Leave the configuration at its defaults but set Input to "4". Press Step repeatedly and watch each node appear.

You will see the input "4" flow into .parse::<i32>(), become Ok(4) (green), then become the plain value 4. That feeds 100.checked_div(n), producing Some(25) (green), which the second strategy unwraps to 25. The function ends with Ok(25) and a green success banner. Notice that on this input the strategy choices do not matter: nothing fails, so nothing needs handling.

Step 2: Make the parse fail, then handle it three ways

Section titled “Step 2: Make the parse fail, then handle it three ways”
Show/Hide Instructions

Set Input to "abc" and keep On Err at .unwrap(). Press Run all. The parse produces Err(ParseIntError) (rose), and .unwrap() insists on a value it does not have, so the pipeline halts with a panic (rose) and the authentic panic message appears. The division stage never even runs.

Now, with the input still "abc", change On Err to ?. Run it again. This time the Err is returned straight to the caller (amber early return), no crash. Finally try On Err set to .unwrap_or(1): the failure is absorbed, n becomes 1, and the pipeline continues to Ok(100). One failing input, three completely different fates, decided entirely by which strategy you chose.

Show/Hide Instructions

Set Input to "0" and On Err back to .unwrap(). The parse now succeeds (0 is a perfectly good number), but 100.checked_div(0) returns None (amber, because there is no quotient). Watch how the On None strategy decides what happens next: .unwrap() panics, .ok_or("divide by zero")? returns an Err to the caller, and .unwrap_or(0) substitutes 0 and succeeds with Ok(0).

Compare this with the C++ mirror panel for the same "0" input: there, 100 / 0 is undefined behaviour, not even a catchable exception. Rust turned a whole category of crashes into an ordinary Option you must address.

You are ready for the challenges.


Result & Option
0/3 challenges

Result & Option Lab: Failure as a Value

The pipeline you are configuring (Rust)

fn run(s: &str) -> Result<i32, &'static str> {
    let n = s.parse::<i32>().unwrap();
    let q = 100i32.checked_div(n).unwrap();
    Ok(q)
}

run("4");

Two things can go wrong: the parse can fail (a Result with a reason) and the division can be impossible (an Option, because absence needs no reason). Neither failure is an exception: both arrive as ordinary values your code must do something with.

Input
On Err
On None

Take the value; PANIC if it is an Err. Then: take the value; PANIC if it is None.

0 / 10

Press Step to watch the value (or the failure) travel through the pipeline.

Meanwhile in C++, with input "4"

int n = std::stoi(s);   // can throw: the signature does not say so
int q = 100 / n;        // n == 0 is undefined behaviour, not an exception

std::stoi("4") returns 4, then 100 / 4 yields 25. The happy path looks identical in both languages; the unhappy paths are where they part company.

Predict the outcome

For each configuration, predict what happens before you run it. Your first answer counts; after submitting you can load the configuration into the pipeline above and watch it play out.

Challenge 1run("abc") with .unwrap() then .ok_or(..)?
Challenge 2run("0") with ? then .ok_or(..)?
Challenge 3run("abc") with .unwrap_or(1) then .unwrap()

Discussion:

Rust splits "something went wrong" into two types with different meanings: Option<T> for absence (no reason needed) and Result<T, E> for failure with a reason. Because both appear in the function signature, a caller can see every failure mode at a glance, and the compiler will not let one be ignored silently. The ? operator keeps the happy path readable while still propagating every failure honestly, which is what exceptions promise without the visibility. C++ has been converging on this design: std::optional (C++17) mirrors Option, though dereferencing an empty one is still undefined behaviour, and std::expected (C++23) mirrors Result. The habit to build is the same in both languages: reserve unwrap (and unchecked access) for cases you can prove cannot fail, and let everything else travel as a value.


For each task: read the question, make a prediction, then use the lab to check. Only expand the explanation once you have done both. Predicting before checking is what builds intuition; reading the answer first builds none. The three “Predict the outcome” challenges inside the lab are graded on your first answer, so commit to a prediction before you submit.

Set the input to "abc" and both strategies to .unwrap(). Before running it, predict: does the function return a value, return an Err, or crash? If it crashes, at which of the two stages?

Show explanation

It panics at the parse. The string "abc" cannot be parsed as an i32, so .parse::<i32>() produces Err(ParseIntError). The .unwrap() strategy says “I promise this is always Ok”, and that promise is now broken, so the thread panics immediately. The division stage never runs at all, because the pipeline has already halted.

This is the central lesson about .unwrap(): it is convenient (one method call instead of a match arm), but it converts a recoverable failure into a hard crash. Reserve it for cases where you can genuinely prove the Err is impossible (a hard-coded literal you know is valid, or a value you have already checked). For anything that depends on outside input, prefer ?, a match, or one of the unwrap_or family.

Set the input to "0" and both strategies to ? (.map_err(...)? on Err, .ok_or(...)? on None). Predict the outcome before running. Pay attention to the colour of the None node when it appears.

Show explanation

It performs a graceful early return: the caller receives Err("divide by zero") and there is no crash. The string "0" parses fine, so the first stage produces Ok(0) and then the value 0. But 100.checked_div(0) returns None (shown in amber, because absence is not in itself an error). The .ok_or("divide by zero")? strategy converts that absence into an Err with a reason and returns it to the caller via the ? operator.

Notice the colour story. The None node is amber, not rose, because at the moment it appears nothing has gone wrong: there genuinely is no quotient for 100 / 0, and that is simply a fact. It only becomes an error because this particular strategy chose to treat the absence as a failure worth reporting. A different strategy (.unwrap_or(0)) would have treated the same None as a reason to substitute a default and carry on. The type system kept the failure visible and let you decide its meaning.

Set the input to "abc", On Err to .unwrap_or(1), and On None to .unwrap(). The input is invalid, yet predict whether this configuration crashes. Then run it and check.

Show explanation

It succeeds, returning Ok(100), despite being handed invalid input. The parse of "abc" fails, but .unwrap_or(1) absorbs that failure by substituting 1 for n. The division is then 100.checked_div(1), which is Some(100), so the second .unwrap() is safe and the function returns Ok(100).

This is worth sitting with. The input was nonsense, the program did not crash, and the caller received a perfectly ordinary-looking Ok(100). .unwrap_or is a genuine tool, ideal when a sensible default really is the right behaviour, but here it has quietly papered over a real problem: someone passed "abc" where a number was expected, and nobody will ever find out. Defaults hide failures. Sometimes that is exactly what you want; sometimes it just buries a bug where it will resurface later, far from its cause. The skill is recognising which situation you are in.

Without using the lab at first, decide which On Err strategy you would choose for each of these goals, then load a matching configuration to confirm your reasoning:

(a) "This input comes straight from a config file; if it is malformed I want the
whole program to stop loudly so I notice immediately during development."
(b) "This is a library function; on bad input I want to hand the problem back to
whoever called me and let them decide."
(c) "This is a best-effort cache lookup; on a miss, just use a sensible fallback
and keep going."
Show explanation

There is a natural fit for each:

GoalStrategyWhy
(a) stop loudly during development.unwrap()A panic is the loud, immediate stop you asked for. Acceptable here precisely because it is development-time and you want to be told at once.
(b) hand the problem back to the caller?Propagates the Err out of your function so the caller chooses what to do. This is the idiomatic default for library code.
(c) best-effort with a fallback.unwrap_or(...)Substitutes a default and continues, which is exactly right when a miss is expected and harmless.

The point is that none of these strategies is “correct” in the abstract; each encodes a different intention, and Rust forces you to state which intention you mean at the point of the failure. That explicitness is the whole value of failure-as-a-value: the handling is visible in the code, not hidden in an invisible exception that may or may not be caught three stack frames up.

In real code the ? operator is the workhorse. It keeps the happy path on the main line of the function while still propagating every failure honestly, which is what exceptions promise but without the visibility. You reach for .unwrap() only when you can prove failure is impossible, and for .unwrap_or only when a default is genuinely the right answer rather than a convenient way to silence a problem.


You have now driven failure through a real pipeline and watched it behave as an ordinary value. The key ideas:

  • Rust splits “something went wrong” into two types with different meanings: Option<T> for absence (no reason needed) and Result<T, E> for failure with a reason.
  • Both appear in the function signature, so a caller can see every way a call can fail at a glance, and the compiler will not let any failure be ignored silently.
  • Neither is an exception. They are values that travel through your code, and you choose at each point what to do with them.
  • .unwrap() is a promise that failure cannot happen; if the promise is broken, the thread panics. Reserve it for cases you can prove are safe.
  • The ? operator propagates a failure to the caller while keeping the happy path readable. It is the idiomatic default.
  • .unwrap_or(default) absorbs a failure by substituting a value. Useful when a default is genuinely right, dangerous when it merely hides a bug.
  • None is amber, not rose, on purpose: absence is not an error until your code decides to treat it as one.

C++ has been converging on the same design. std::optional (C++17) mirrors Option, though dereferencing an empty one is still undefined behaviour, and std::expected (C++23) mirrors Result. The habit to build is identical in both languages: reserve unwrap and unchecked access for cases you can prove cannot fail, and let everything else travel as a value.

Quiz
Select 0/1

What is the essential difference between Option<T> and Result<T, E>?

Quiz
Select 0/1

A call is s.parse::<i32>().unwrap() and s holds "abc". What happens?

Quiz
Select 0/3

Which statements about handling strategies are sound advice? (Select all that apply.)