Skip to content

Returning None on error: the job to be suspicious of

Level: 201 · working knowledge

One line: None-on-error is right when the failure has exactly one cause — and a downgrade whenever you are discarding a Result that already told you which cause it was.

std::option's list ↗ includes "return value for otherwise reporting simple errors, where None is returned on error". The load-bearing word is simple, and it is the word most easily skipped.


The function everyone writes first

fn parse_integer(input: &str) -> Option<i32> {
    match input.parse::<i32>() {
        Ok(value) => Some(value),
        Err(_) => None,
    }
}

Two observations, in increasing order of importance.

First, it is already a one-liner. That entire body is what .ok() does:

fn parse_integer(input: &str) -> Option<i32> {
    input.parse().ok()
}

Any time you find yourself matching a Result only to build Some/None, .ok() is the method you are re-implementing. (.err() is its mirror, keeping the error and discarding the value.)

Second — and this is the real lesson — that function is a downgrade. parse already returned a Result carrying the reason. Writing .ok() throws it in the bin.

What the downgrade throws away

Five inputs, four genuinely different causes, one indistinguishable answer:

Input As Result As Option
"abc" Err(ParseIntError { kind: InvalidDigit }) None
"" Err(ParseIntError { kind: Empty }) None
"99999999999999" Err(ParseIntError { kind: PosOverflow }) None
"-99999999999999" Err(ParseIntError { kind: NegOverflow }) None
"4.5" Err(ParseIntError { kind: InvalidDigit }) None

The cost is not abstract — it lands on whoever reads your error message:

input "99999999999999"
  kept Result  -> that didn't work — number too large to fit in target type
  downgraded   -> Failed to parse integer
input ""
  kept Result  -> that didn't work — cannot parse integer from empty string
  downgraded   -> Failed to parse integer

A user who typed twenty digits gets told exactly the same thing as one who typed nothing. The information existed, was computed for free, and the return type discarded it. That is the whole objection: not that Option is imprecise in principle, but that here you had the precision and gave it up.

When None-on-error is exactly right

The test is unchanged from Option vs Result: could the caller sensibly ask "why not?" When the answer is no, None is not lossy — there was nothing to lose.

'7'.to_digit(10)   // Some(7)
'x'.to_digit(10)   // None  — it is not a digit in base 10. That is the only story.

Same for a lookup: a name is either on the roster or it is not. Same for checked_div: the divisor was zero. These are simple errors in the sense the docs mean — one cause, nothing for a caller to branch on.

The tell that you have crossed the line is a caller writing None handling that has to guess at a cause, or a doc comment explaining in prose what None means. If the prose is necessary, the type should have carried it.

The default, and the escape hatch

Return the Result. A caller who does not care can always call .ok() and get your Option; a caller who does care cannot reconstruct what you have already thrown away. Downgrading is a decision that belongs to the consumer, not the producer — and .ok() puts it exactly one character away from them.

When you need to go the other direction, ok_or supplies the reason Option could not carry:

fn read_field(raw: Option<&str>) -> Result<i32, FieldError> {
    let text = raw.ok_or(FieldError::Missing)?;
    text.parse::<i32>().map_err(FieldError::NotANumber)
}

Note what that function does with each half: the absence of the field and the malformedness of its contents are different failures, so they become different variants. That is what "not a simple error" looks like in practice.


Practice

Four causes, one None. A config line gives a seat count. It can be missing, non-numeric, zero, or larger than the row supports. Write the version that returns Option<u32>, then the version that keeps the reason, and print both over the same five inputs.

Try writing the operator-facing error message from the Option version. You will find yourself guessing which of the four happened — that guess is the downgrade, and it is invisible in the signature. Then find the call in your own solution where None really was the right answer all along.

Solution

none_on_error_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: four causes, one `None` — and then four causes kept apart.
//!
//!   rustc --edition 2024 none_on_error_kata.rs -o /tmp/noek && /tmp/noek

use std::fmt;
use std::num::ParseIntError;

/// The line everyone writes first. `.ok()` is where the reason goes to die.
fn columns_lossy(raw: &str) -> Option<u32> {
    raw.trim().parse::<u32>().ok().filter(|n| (1..=20).contains(n))
}

#[derive(Debug)]
enum ColumnsError {
    Missing,
    NotANumber(ParseIntError),
    Zero,
    TooMany(u32),
}

impl fmt::Display for ColumnsError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ColumnsError::Missing => write!(f, "no seat count was given"),
            ColumnsError::NotANumber(e) => write!(f, "not a whole number: {e}"),
            ColumnsError::Zero => write!(f, "a table with 0 columns holds nothing"),
            ColumnsError::TooMany(n) => write!(f, "{n} columns is past the 20 this row supports"),
        }
    }
}

fn columns(raw: &str) -> Result<u32, ColumnsError> {
    let raw = raw.trim();
    if raw.is_empty() {
        return Err(ColumnsError::Missing);
    }
    let n = raw.parse::<u32>().map_err(ColumnsError::NotANumber)?;
    match n {
        0 => Err(ColumnsError::Zero),
        1..=20 => Ok(n),
        _ => Err(ColumnsError::TooMany(n)),
    }
}

fn main() {
    let lines = ["3", "", "three", "0", "99"];

    println!("Option — the caller cannot tell a typo from a policy limit:");
    for raw in lines {
        println!("  {:>7} -> {:?}", format!("{raw:?}"), columns_lossy(raw));
    }

    println!("\nResult — every one of them can be answered differently:");
    for raw in lines {
        match columns(raw) {
            Ok(n) => println!("  {:>7} -> {n} columns", format!("{raw:?}")),
            Err(e) => println!("  {:>7} -> {e}", format!("{raw:?}")),
        }
    }

    println!("\nThe one case where None was right all along:");
    let rows = ["ada", "ben"];
    println!("  rows.iter().position(|b| *b == \"cara\") -> {:?}", rows.iter().position(|b| *b == "cara"));
    println!("      There is exactly one reason that is absent, and the caller");
    println!("      already knows it. Nothing was discarded to say None here.");
}

Verified output of none_on_error_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

Option — the caller cannot tell a typo from a policy limit:
      "3" -> Some(3)
       "" -> None
  "three" -> None
      "0" -> None
     "99" -> None

Result — every one of them can be answered differently:
      "3" -> 3 columns
       "" -> no seat count was given
  "three" -> not a whole number: invalid digit found in string
      "0" -> a table with 0 columns holds nothing
     "99" -> 99 columns is past the 20 this row supports

The one case where None was right all along:
  rows.iter().position(|b| *b == "cara") -> None
      There is exactly one reason that is absent, and the caller
      already knows it. Nothing was discarded to say None here.

The verified output

Verified output of none_on_error.rs — regenerated by tools/run_examples.py, never hand-typed.

──── Step 1: The function, and the one-liner it already is
  parse_integer("42") -> Parsed value: 42
  parse_integer("abc") -> Failed to parse integer
  "42".parse::<i32>().ok()  -> Some(42)
  "abc".parse::<i32>().ok() -> None
  agree on every input?      true

──── Step 2: What the downgrade threw away
  input              as Result                                      as Option
  "abc"              Err(ParseIntError { kind: InvalidDigit })      None
  ""                 Err(ParseIntError { kind: Empty })             None
  "99999999999999"   Err(ParseIntError { kind: PosOverflow })       None
  "-99999999999999"  Err(ParseIntError { kind: NegOverflow })       None
  "4.5"              Err(ParseIntError { kind: InvalidDigit })      None
      FIVE inputs, FOUR distinct causes — InvalidDigit, Empty, PosOverflow,
      NegOverflow — and all of them arrive as the same None.

──── Step 3: The cost lands on whoever reads the message
  input "99999999999999"
    kept Result  -> that didn't work — number too large to fit in target type
    downgraded   -> Failed to parse integer
  input ""
    kept Result  -> that didn't work — cannot parse integer from empty string
    downgraded   -> Failed to parse integer
      A user who typed too many digits is told the same thing as one who
      typed nothing. The information existed; the return type discarded it.

──── Step 4: When None-on-error is exactly right
  '7'.to_digit(10)   -> Some(7)
  'x'.to_digit(10)   -> None
  'f'.to_digit(16)   -> Some(15)
  find("ada")        -> Some(1)
  find("zoe")        -> None
      One cause each: the char is not a digit in that radix; the name is not
      on the roster. There is no second reason for a caller to distinguish.

──── Step 5: Going the other way when you need to
  read_field(Some("42")) -> Ok(42)
  read_field(None) -> Err: the field was not supplied
  read_field(Some("abc")) -> Err: not a number — invalid digit found in string
      ok_or supplies the reason Option could not carry. And note the default:
      return the Result. A caller who does not care can always call .ok();
      a caller who does care cannot invent what you already threw away.

Run it yourself:

rustc --edition 2024 17_Option_and_Result/none_on_error/examples/none_on_error.rs -o /tmp/noe && /tmp/noe

See also

Po polsku

Funkcja parse_integer, którą prawie każdy pisze pierwszą, ma dwie wady, i ta druga jest poważniejsza. Po pierwsze, cały jej match to jedna metoda ze standardowej biblioteki: input.parse().ok() — jeśli rozbierasz Result tylko po to, żeby zbudować Some albo None, piszesz od nowa .ok() (a .err() jest jego lustrzanym odbiciem: zatrzymuje błąd, wyrzuca wartość). Po drugie — i to jest właściwa lekcja — taka funkcja to degradacja (downgrade): parse już policzył przyczynę niepowodzenia i podał ją za darmo, a .ok() wyrzuca ją do kosza.

Widać to na liczbach z tej strony: pięć wejść, cztery różne przyczyny (InvalidDigit, Empty, PosOverflow, NegOverflow) i jedna, nierozróżnialna odpowiedź None. Rachunek płaci ten, kto czyta komunikat: użytkownik, który wpisał kilkanaście cyfr, dostaje dokładnie to samo zdanie co ten, który nie wpisał nic — „Failed to parse integer”, zamiast „number too large to fit in target type” i „cannot parse integer from empty string”. Dla osoby przychodzącej z Pythona to szczególnie zdradliwe, bo tam wyboru nie ma: nieudany re.match zwraca None i na tym kończy się słownik. W Ruscie None jest decyzją, a akurat ta oddaje informację, którą już się miało.

Kiedy więc None jest właściwą odpowiedzią? Test jest jednozdaniowy: czy wywołujący ma sens pytać „dlaczego nie?”. Przy 'x'.to_digit(10) nie ma — znak po prostu nie jest cyfrą w tej podstawie; tak samo przy nazwisku, którego nie ma na liście, i przy dzieleniu przez zero w checked_div. To są „proste błędy” w tym sensie, o który chodzi dokumentacji: jedna przyczyna, nie ma czego rozgałęziać. Sygnałem, że granica została przekroczona, jest obsługa None, która musi zgadywać przyczynę, albo komentarz dokumentacyjny tłumaczący prozą, co tu znaczy None — jeśli proza jest potrzebna, to informację powinien nieść typ. Domyślnie więc zwracaj Result: kto nie chce szczegółów, dopisze .ok() i ma twoją opcję, ale kto ich potrzebuje, nie odtworzy tego, co producent już wyrzucił. W drugą stronę służy ok_or, które dokłada powód, jakiego Option nie potrafił unieść.

Szukaj po polsku: obsługa błędów w Ruscie · utrata informacji o błędzie · rust Result ok() vs ok_or · rust ParseIntError kind · rust Option vs Result error handling