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
CalcErrorenum withParseError,DivisionByZero, andUnknownOperatorvariants. - A
Displayimplementation forCalcErrorso 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 usingmatchto 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
enumdefinitions and matching on them withmatch. Result<T, E>— the difference between theOkandErrvariants.- The
?operator — what it does when theResultit is applied to isErr. - 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 toevaluate(), guarding against a zero divisor the same way/does. - Keep a
Vec<String>history of every expression and result, and add ahistorycommand 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.