ExamplesBy LevelBy TopicLearning Paths
1022 Intermediate

1022-sentinel-vs-result — Sentinel Values vs Result

Functional Programming

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

  • • Recognize the failure modes of sentinel-value APIs
  • • Convert sentinel-based functions to Option-returning equivalents
  • • Convert Option to Result when the absence reason matters
  • • Understand the compile-time safety guarantee of Option and Result
  • • Know which one to choose for a given situation
  • Code 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

  • Type safety: Rust's 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.
  • Expressive power: Result carries a reason for failure; Option does not. Sentinel values can encode multiple failure modes (different magic numbers) but are error-prone.
  • Standard library consistency: OCaml's and Rust's standard libraries both use 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);
        }
    }
    ✓ Tests Rust test suite
    #[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

  • • Same progression: sentinel → Option → Result
  • • OCaml culture strongly favors Option for lookups
  • List.assoc_opt, Hashtbl.find_opt return Option
  • • No null in OCaml — already safer than most languages
  • Rust Approach

  • • Identical progression: sentinel → Option → Result
  • Option<usize> instead of -1i32 for "not found"
  • • Standard library consistently uses Option/Result
  • • No null at all — Option<T> is the only way to express absence
  • Comparison Table

    AspectSentinelOptionResult
    Type safetyNoneCompiler-enforcedCompiler-enforced
    Error infoImplicit"missing" onlyWhy it's missing
    Ambiguity-1 might be validNone is clearErr(reason) is clear
    Forgotten checkSilent bugCompile errorCompile error
    Use whenNever (legacy code)Absence is expectedAbsence needs explanation

    Exercises

  • Write a wrapper around a hypothetical C-style API function c_find(haystack: &[i32], needle: i32) -> i32 that returns -1 on failure. Convert it to return Option<usize>.
  • Chain 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.
  • Design a LookupTable struct that internally uses a HashMap but exposes sentinel-free methods returning Option and Result.
  • Open Source Repos