1022-sentinel-vs-result — Sentinel Values vs Result
Tutorial
The Problem
Sentinel values — magic numbers like -1 for "not found" or empty strings for "missing" — are a C-era pattern for encoding failure in a type that is otherwise used for success. They require callers to check the return value against the sentinel manually, and forgetting to do so compiles without error. The strlen convention of returning -1 on failure, strtol returning 0 with errno set, and many POSIX APIs use this pattern.
Rust's Option<T> and Result<T, E> make absence and failure explicit in the type, forcing callers to handle both cases at the type-checking stage. This example contrasts both approaches on equivalent problems.
🎯 Learning Outcomes
Option-returning equivalentsOption to Result when the absence reason mattersOption and ResultCode Example
#![allow(clippy::all)]
// 1022: Sentinel Values vs Result
// Migrating sentinel values to Option/Result
// Approach 1: Sentinel values — the C way (DON'T DO THIS in Rust)
fn find_index_sentinel(haystack: &[i32], needle: i32) -> i32 {
for (i, &val) in haystack.iter().enumerate() {
if val == needle {
return i as i32;
}
}
-1 // sentinel: "not found"
}
fn get_config_sentinel(key: &str) -> &str {
match key {
"port" => "8080",
_ => "", // sentinel: "missing"
}
}
// Approach 2: Option — explicit absence (PREFERRED for lookups)
fn find_index(haystack: &[i32], needle: i32) -> Option<usize> {
haystack.iter().position(|&x| x == needle)
}
fn get_config(key: &str) -> Option<&str> {
match key {
"port" => Some("8080"),
_ => None,
}
}
// Approach 3: Result — absence with reason (PREFERRED when error matters)
fn find_index_result(haystack: &[&str], needle: &str) -> Result<usize, String> {
haystack
.iter()
.position(|&x| x == needle)
.ok_or_else(|| format!("{} not in list", needle))
}
fn get_config_result(key: &str) -> Result<&str, String> {
match key {
"port" => Ok("8080"),
_ => Err(format!("key not found: {}", key)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sentinel_found() {
assert_eq!(find_index_sentinel(&[1, 2, 3], 2), 1);
}
#[test]
fn test_sentinel_not_found() {
assert_eq!(find_index_sentinel(&[1, 2, 3], 9), -1);
// Problem: caller must remember to check for -1
}
#[test]
fn test_option_found() {
assert_eq!(find_index(&[1, 2, 3], 2), Some(1));
}
#[test]
fn test_option_not_found() {
assert_eq!(find_index(&[1, 2, 3], 9), None);
// Compiler forces you to handle None
}
#[test]
fn test_result_found() {
assert_eq!(find_index_result(&["a", "b", "c"], "b"), Ok(1));
}
#[test]
fn test_result_not_found() {
let err = find_index_result(&["a", "b"], "z").unwrap_err();
assert!(err.contains("not in list"));
}
#[test]
fn test_config_sentinel_ambiguity() {
// Is "" a valid config value or "missing"? Can't tell!
assert_eq!(get_config_sentinel("missing"), "");
// With Option, it's clear:
assert_eq!(get_config("missing"), None);
}
#[test]
fn test_config_result() {
assert_eq!(get_config_result("port"), Ok("8080"));
assert!(get_config_result("unknown").is_err());
}
#[test]
fn test_migration_pattern() {
// Common migration: wrap sentinel check in Option
fn migrate(val: i32) -> Option<i32> {
if val == -1 {
None
} else {
Some(val)
}
}
assert_eq!(migrate(find_index_sentinel(&[1, 2], 2)), Some(1));
assert_eq!(migrate(find_index_sentinel(&[1, 2], 9)), None);
}
}Key Differences
Option<usize> makes the compiler enforce the check; a sentinel i32 does not — you can accidentally use -1 as an index.? operator**: Option<T> propagates with ? just like Result<T, E>; sentinel values require manual checks at every call site.Result carries a reason for failure; Option does not. Sentinel values can encode multiple failure modes (different magic numbers) but are error-prone.option/Option uniformly; C APIs are inconsistent with their sentinel conventions.OCaml Approach
OCaml eliminated sentinel values early. The standard library uses option types throughout: List.find_opt, String.index_opt, Hashtbl.find_opt. Exceptions play a role for unexpected failures, but option is idiomatic for "might not be present":
let find_index xs x =
let rec go i = function
| [] -> None
| h :: t -> if h = x then Some i else go (i + 1) t
in
go 0 xs
Full Source
#![allow(clippy::all)]
// 1022: Sentinel Values vs Result
// Migrating sentinel values to Option/Result
// Approach 1: Sentinel values — the C way (DON'T DO THIS in Rust)
fn find_index_sentinel(haystack: &[i32], needle: i32) -> i32 {
for (i, &val) in haystack.iter().enumerate() {
if val == needle {
return i as i32;
}
}
-1 // sentinel: "not found"
}
fn get_config_sentinel(key: &str) -> &str {
match key {
"port" => "8080",
_ => "", // sentinel: "missing"
}
}
// Approach 2: Option — explicit absence (PREFERRED for lookups)
fn find_index(haystack: &[i32], needle: i32) -> Option<usize> {
haystack.iter().position(|&x| x == needle)
}
fn get_config(key: &str) -> Option<&str> {
match key {
"port" => Some("8080"),
_ => None,
}
}
// Approach 3: Result — absence with reason (PREFERRED when error matters)
fn find_index_result(haystack: &[&str], needle: &str) -> Result<usize, String> {
haystack
.iter()
.position(|&x| x == needle)
.ok_or_else(|| format!("{} not in list", needle))
}
fn get_config_result(key: &str) -> Result<&str, String> {
match key {
"port" => Ok("8080"),
_ => Err(format!("key not found: {}", key)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sentinel_found() {
assert_eq!(find_index_sentinel(&[1, 2, 3], 2), 1);
}
#[test]
fn test_sentinel_not_found() {
assert_eq!(find_index_sentinel(&[1, 2, 3], 9), -1);
// Problem: caller must remember to check for -1
}
#[test]
fn test_option_found() {
assert_eq!(find_index(&[1, 2, 3], 2), Some(1));
}
#[test]
fn test_option_not_found() {
assert_eq!(find_index(&[1, 2, 3], 9), None);
// Compiler forces you to handle None
}
#[test]
fn test_result_found() {
assert_eq!(find_index_result(&["a", "b", "c"], "b"), Ok(1));
}
#[test]
fn test_result_not_found() {
let err = find_index_result(&["a", "b"], "z").unwrap_err();
assert!(err.contains("not in list"));
}
#[test]
fn test_config_sentinel_ambiguity() {
// Is "" a valid config value or "missing"? Can't tell!
assert_eq!(get_config_sentinel("missing"), "");
// With Option, it's clear:
assert_eq!(get_config("missing"), None);
}
#[test]
fn test_config_result() {
assert_eq!(get_config_result("port"), Ok("8080"));
assert!(get_config_result("unknown").is_err());
}
#[test]
fn test_migration_pattern() {
// Common migration: wrap sentinel check in Option
fn migrate(val: i32) -> Option<i32> {
if val == -1 {
None
} else {
Some(val)
}
}
assert_eq!(migrate(find_index_sentinel(&[1, 2], 2)), Some(1));
assert_eq!(migrate(find_index_sentinel(&[1, 2], 9)), None);
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sentinel_found() {
assert_eq!(find_index_sentinel(&[1, 2, 3], 2), 1);
}
#[test]
fn test_sentinel_not_found() {
assert_eq!(find_index_sentinel(&[1, 2, 3], 9), -1);
// Problem: caller must remember to check for -1
}
#[test]
fn test_option_found() {
assert_eq!(find_index(&[1, 2, 3], 2), Some(1));
}
#[test]
fn test_option_not_found() {
assert_eq!(find_index(&[1, 2, 3], 9), None);
// Compiler forces you to handle None
}
#[test]
fn test_result_found() {
assert_eq!(find_index_result(&["a", "b", "c"], "b"), Ok(1));
}
#[test]
fn test_result_not_found() {
let err = find_index_result(&["a", "b"], "z").unwrap_err();
assert!(err.contains("not in list"));
}
#[test]
fn test_config_sentinel_ambiguity() {
// Is "" a valid config value or "missing"? Can't tell!
assert_eq!(get_config_sentinel("missing"), "");
// With Option, it's clear:
assert_eq!(get_config("missing"), None);
}
#[test]
fn test_config_result() {
assert_eq!(get_config_result("port"), Ok("8080"));
assert!(get_config_result("unknown").is_err());
}
#[test]
fn test_migration_pattern() {
// Common migration: wrap sentinel check in Option
fn migrate(val: i32) -> Option<i32> {
if val == -1 {
None
} else {
Some(val)
}
}
assert_eq!(migrate(find_index_sentinel(&[1, 2], 2)), Some(1));
assert_eq!(migrate(find_index_sentinel(&[1, 2], 9)), None);
}
}
Deep Comparison
Sentinel Values vs Result — Comparison
Core Insight
Sentinel values (-1, null, "") encode failure in the success type. Option/Result use separate types, making failure handling compiler-enforced.
OCaml Approach
List.assoc_opt, Hashtbl.find_opt return OptionRust Approach
Option<usize> instead of -1i32 for "not found"Option<T> is the only way to express absenceComparison Table
| Aspect | Sentinel | Option | Result |
|---|---|---|---|
| Type safety | None | Compiler-enforced | Compiler-enforced |
| Error info | Implicit | "missing" only | Why it's missing |
| Ambiguity | -1 might be valid | None is clear | Err(reason) is clear |
| Forgotten check | Silent bug | Compile error | Compile error |
| Use when | Never (legacy code) | Absence is expected | Absence needs explanation |
Exercises
c_find(haystack: &[i32], needle: i32) -> i32 that returns -1 on failure. Convert it to return Option<usize>.find_index and get_config together: find the index of "port" in a list of known keys, then look up the config value by that index.LookupTable struct that internally uses a HashMap but exposes sentinel-free methods returning Option and Result.