Overview
A calculator that reads text typed by a human has exactly one job that matters more than the arithmetic: deciding, cleanly, what to do when that text is not a valid expression. Rust does not have exceptions — there is no `try`/`catch` anywhere in this project — so every function that can fail says so in its return type by returning a `Result<T, E>`, and every caller is forced by the compiler to acknowledge that the `Err` case exists, even if all it does is print a message and move on.
By the end of this tutorial you will have a small REPL (read-eval-print loop) that accepts expressions like `3 + 4` typed one per line, parses them into two operands and an operator, evaluates them with `match`, and reports specific, readable errors for bad numbers, unknown operators, and division by zero — all through a custom `CalcError` enum and the `?` operator, without a single `unwrap()` in the parsing or evaluation path.
- A `CalcError` enum with `ParseError`, `DivisionByZero`, and `UnknownOperator` variants.
- A `Display` implementation for `CalcError` so it prints a readable message instead of its raw Debug form.
- A `parse_expression()` function that splits and validates a line of input.
- An `evaluate()` function using `match` to dispatch on the operator character.
- A `calculate()` function chaining parsing and evaluation together with the `?` operator.
- A REPL loop that reads a line, calculates it, and keeps running after both success and failure.
Prerequisites
- Basic `enum` definitions and matching on them with `match`.
- `Result<T, E>` — the difference between the `Ok` and `Err` variants.
- The `?` operator — what it does when the `Result` it is applied to is `Err`.
- String methods — `.trim()`, `.split_whitespace()`, and `.parse::<T>()`.
- Implementing a trait, specifically `std::fmt::Display`, well enough to follow along.
Project Structure
Create the project with `cargo new calculator` and write everything in `src/main.rs`. The file is organized bottom-up: `CalcError` and its `Display` implementation come first, since every other function's signature mentions it; then `parse_expression()`, which turns a raw `&str` into two `f64` operands and a `char` operator; then `evaluate()`, which turns those three values into a result; then `calculate()`, a thin function that chains the previous two together; and finally `main()`, which is nothing more than a loop calling `calculate()` and printing whichever variant of the `Result` it gets back.
Notice that none of `parse_expression()`, `evaluate()`, or `calculate()` ever calls `println!` on an error — only `main()` does. Keeping the error-reporting decision in one place (the REPL loop) instead of scattering `println!` calls through the parsing and evaluation logic is what makes those functions reusable later, for example from a test, a web handler, or a different front end entirely.
Step 1: Define a CalcError Enum
Each variant of `CalcError` carries exactly the data needed to explain what went wrong: `ParseError` holds the offending text, `UnknownOperator` holds the offending character, and `DivisionByZero` needs no payload at all, since there is nothing more to say about it. Implementing `std::fmt::Display` (not just deriving `Debug`) is what lets `{e}` inside a `println!` format string print a human sentence instead of Rust's internal `CalcError::ParseError("abc")` representation; implementing `std::error::Error` on top of that is what makes `CalcError` compatible with the wider ecosystem of Rust error-handling code that expects a real error type.
use std::fmt;
// Each variant carries exactly the data needed to explain the failure --// this is the idiomatic alternative to throwing three different exception// types the way another language might.#[derive(Debug)]enum CalcError { ParseError(String), // Holds the text that failed to parse as a number DivisionByZero, // No payload needed; the variant name says everything UnknownOperator(char), // Holds the character that wasn't +, -, *, or /}
// Display controls what {e} / {} prints for a CalcError. Deriving Debug// (above) already gives us {:?}, but Debug output is meant for programmers,// not end users -- Display is what a REPL should actually show them.impl fmt::Display for CalcError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { CalcError::ParseError(text) => write!(f, "could not parse '{text}' as a number"), CalcError::DivisionByZero => write!(f, "division by zero"), CalcError::UnknownOperator(op) => write!(f, "unknown operator '{op}'"), } }}
// Implementing the standard Error trait (it has no required methods once// Debug and Display exist) marks CalcError as a first-class error type that// works with the wider Rust error-handling ecosystem, e.g. Box<dyn Error>.impl std::error::Error for CalcError {}Step 2: Parse an Expression String
`parse_expression()` expects exactly three whitespace-separated tokens: a number, an operator, and a number. `.split_whitespace()` returns an iterator, and `.collect()` gathers it into a `Vec<&str>` so the token count can be checked with a plain `if`. Each `&str` operand is converted with `.parse::<f64>()`, which itself returns a `Result<f64, ParseFloatError>` — that error type is discarded with `.map_err(...)` and replaced with a `CalcError::ParseError` that actually names the offending token.
// Splits "3 + 4" into (3.0, '+', 4.0). Returns Result rather than panicking,// because malformed input from a human typing at a prompt is an everyday// occurrence, not an exceptional program-breaking event.fn parse_expression(input: &str) -> Result<(f64, char, f64), CalcError> { let tokens: Vec<&str> = input.trim().split_whitespace().collect(); if tokens.len() != 3 { return Err(CalcError::ParseError(input.to_string())); // Wrong shape entirely, e.g. "3 +" or "3 + 4 + 5" }
// parse::<f64>() returns Result<f64, ParseFloatError>; map_err() swaps that // error type for our own CalcError so every function in this file agrees // on one error type instead of juggling several. let left: f64 = tokens[0].parse().map_err(|_| CalcError::ParseError(tokens[0].to_string()))?; let right: f64 = tokens[2].parse().map_err(|_| CalcError::ParseError(tokens[2].to_string()))?;
// The operator token must be exactly one character; anything else (e.g. // "++") is treated the same as a bad number -- a parse failure, not an // UnknownOperator, since UnknownOperator is reserved for Step 3's match. let operator = tokens[1].chars().next().ok_or_else(|| CalcError::ParseError(tokens[1].to_string()))?; if tokens[1].len() != 1 { return Err(CalcError::ParseError(tokens[1].to_string())); }
Ok((left, operator, right))}Click Run to see what this code prints.
Step 3: Evaluate the Expression With match
`evaluate()` receives the already-parsed operands and operator and does the actual arithmetic. The `match` on `operator` is exhaustive: three arms handle `+`, `-`, and `*` unconditionally, the `/` arm has its own nested `if` to guard against a zero divisor before dividing, and the trailing `other =>` arm is required by the compiler because a `char` has vastly more possible values than the four this calculator understands.
fn evaluate(left: f64, operator: char, right: f64) -> Result<f64, CalcError> { match operator { '+' => Ok(left + right), '-' => Ok(left - right), '*' => Ok(left * right), '/' => { if right == 0.0 { Err(CalcError::DivisionByZero) // Caught here, before the divide, not after } else { Ok(left / right) } } other => Err(CalcError::UnknownOperator(other)), // Required: match must cover every possible char }}Step 4: Chain Parsing and Evaluation With ?
This is the step where the `?` operator does the most visible work. `calculate()` calls `parse_expression(input)?` — if parsing failed, `calculate()` returns that same `Err(CalcError::...)` immediately, and `evaluate(left, operator, right)` never even runs. Written without `?`, this function would need an explicit `match` on the result of `parse_expression()` just to unwrap the success case and re-wrap the failure case; `?` does exactly that unwrapping in a single character, for any function whose return type is a compatible `Result`.
// A thin function that chains Step 2 and Step 3 together. Note it does no// printing itself -- it only ever returns a Result, leaving the decision of// how to report success or failure entirely to the caller (main(), in Step 5).fn calculate(input: &str) -> Result<f64, CalcError> { let (left, operator, right) = parse_expression(input)?; // Short-circuits here if parsing failed evaluate(left, operator, right) // Last expression, no semicolon: this is the return value}Click Run to see what this code prints.
Step 5: Build the REPL Loop
The REPL reads one line per iteration with `io::stdin().read_line(&mut buffer)`, and `match calculate(line.trim())` handles both outcomes explicitly: `Ok(result)` prints the answer, `Err(e)` prints `e` (routed through the `Display` implementation from Step 1, not the raw `Debug` form). Typing `exit` breaks the loop; nothing else — not a bad expression, not a division by zero — ever crashes the program, because every failure path returns a value instead of panicking.
use std::io::{self, Write};
fn main() { println!("Rust Calculator -- type an expression like '3 + 4', or 'exit' to quit.");
loop { print!("> "); io::stdout().flush().unwrap(); // Ensure the "> " prompt is visible before read_line blocks for input
let mut line = String::new(); if io::stdin().read_line(&mut line).is_err() { println!("Failed to read input, try again."); continue; } let line = line.trim();
if line.eq_ignore_ascii_case("exit") { println!("Goodbye!"); break; } if line.is_empty() { continue; // Silently re-prompt on a blank line instead of reporting a parse error }
match calculate(line) { Ok(result) => println!("= {result}"), Err(e) => println!("Error: {e}"), // {e} uses the Display impl from Step 1, not the raw enum Debug form } }}Complete Code
Here is the full program assembled in the correct order, ready to save as `src/main.rs` and run with `cargo run`.
use std::fmt;use std::io::{self, Write};
#[derive(Debug)]enum CalcError { ParseError(String), DivisionByZero, UnknownOperator(char),}
impl fmt::Display for CalcError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { CalcError::ParseError(text) => write!(f, "could not parse '{text}' as a number"), CalcError::DivisionByZero => write!(f, "division by zero"), CalcError::UnknownOperator(op) => write!(f, "unknown operator '{op}'"), } }}
impl std::error::Error for CalcError {}
fn parse_expression(input: &str) -> Result<(f64, char, f64), CalcError> { let tokens: Vec<&str> = input.trim().split_whitespace().collect(); if tokens.len() != 3 { return Err(CalcError::ParseError(input.to_string())); }
let left: f64 = tokens[0].parse().map_err(|_| CalcError::ParseError(tokens[0].to_string()))?; let right: f64 = tokens[2].parse().map_err(|_| CalcError::ParseError(tokens[2].to_string()))?;
let operator = tokens[1].chars().next().ok_or_else(|| CalcError::ParseError(tokens[1].to_string()))?; if tokens[1].len() != 1 { return Err(CalcError::ParseError(tokens[1].to_string())); }
Ok((left, operator, right))}
fn evaluate(left: f64, operator: char, right: f64) -> Result<f64, CalcError> { match operator { '+' => Ok(left + right), '-' => Ok(left - right), '*' => Ok(left * right), '/' => { if right == 0.0 { Err(CalcError::DivisionByZero) } else { Ok(left / right) } } other => Err(CalcError::UnknownOperator(other)), }}
fn calculate(input: &str) -> Result<f64, CalcError> { let (left, operator, right) = parse_expression(input)?; evaluate(left, operator, right)}
fn main() { println!("Rust Calculator -- type an expression like '3 + 4', or 'exit' to quit.");
loop { print!("> "); io::stdout().flush().unwrap();
let mut line = String::new(); if io::stdin().read_line(&mut line).is_err() { println!("Failed to read input, try again."); continue; } let line = line.trim();
if line.eq_ignore_ascii_case("exit") { println!("Goodbye!"); break; } if line.is_empty() { continue; }
match calculate(line) { Ok(result) => println!("= {result}"), Err(e) => println!("Error: {e}"), } }}Sample Run
Click Run to see what this code prints.
Extend This Project
- Support expressions with more than one operator (e.g. `3 + 4 * 2`) by writing a small tokenizer and applying standard operator precedence.
- Add a `%` (modulo) operator to `evaluate()`, guarding against a zero divisor the same way `/` does.
- Keep a `Vec<String>` history of every expression and result, and add a `history` command that prints it.
- Support parentheses by converting the expression to postfix (Reverse Polish) notation before evaluating.
- Replace `CalcError::ParseError(String)` with a variant that also records which token index failed, so error messages can point at the exact position.
Summary
You built a calculator where every possible failure — a bad number, an unknown operator, a division by zero — is a named variant of your own `CalcError` enum rather than a crash or a silently wrong answer. The `parse_expression()` → `evaluate()` → `calculate()` chain, threaded together with the `?` operator, is the same shape you will reach for anywhere a Rust program needs to perform several fallible steps in sequence and stop cleanly at the first one that fails.