Learn Error Handling in Rust By Building a TOML Config Parser
Read this series in order: learning-rust
- Learn Rust Basics By Building a Brainfuck Interpreter
- Learn Rust Ownership and Borrowing By Building Mini Grep
- Build a JSON Parser in Rust from Scratch
- Learn Error Handling in Rust By Building a TOML Config Parser (current)
In this post, we are going to learn about error handling in Rust. Once we cover all the concepts, we will build a TOML config parser with schema validation. Every error must be handled properly using Result. Let's start.
You can get the source code from here
Why panic! and unwrap Are Not Enough
In the previous articles, we used panic! and .expect() and .unwrap() all over the place. When something went wrong, we just crashed the program. Let me remind you:
In the JSON parser, when the lexer hit an unexpected character, we did this:
panic!("Unexpected character: {}", c as char);
And when the parser encountered a token it didn't expect:
panic!("Expected ':' in object");
This is fine for a learning exercise. But real software can't just crash when something goes wrong. A web server can't crash because one request had bad input, it needs to return an error to that client and keep serving others. A config parser can't crash because one field has the wrong type, it needs to tell the user exactly what's wrong and move on.
Here's the rule you should internalise:
panic!is for programmer bugs: things that should never happen if your code is correct. Like indexing past the end of an array, or dividing by zero. These are "I messed up" situations.
Resultis for expected failures: things that can happen even when your code is perfect. Like bad user input, missing files, network timeouts. These are "something went wrong and I need to handle it" situations.
In this article, we are going to replace every panic! and .unwrap() with proper Result based error handling. By the end, our program will never crash unexpectedly. It will always produce a clear, specific error message telling you exactly what went wrong and where.
Option<T> - Dealing with Absence
Before we get to Result, let's talk about its simpler cousin: Option.
How many times have you written code that returns null or -1 or some magic value to signal "nothing found"? In Rust, we don't do that. Instead, we use Option<T>.
Option<T> is an enum with two variants:
enum Option<T> {
Some(T), // there is a value, and it's of type T
None, // there is no value
}
Instead of returning null or -1, a function returns Option<T>. If it has a value, it returns Some(value). If not, it returns None.
Creating Options
let some_number = Some(42);
let some_string = Some("hello");
let nothing: Option<i32> = None;
When you create a Some value, Rust can infer the type T. But when you create a None, Rust needs to know what type T would be if it were Some, so you often need a type annotation.
Pattern Matching on Option
You can match on an Option just like any other enum:
let maybe_number = Some(42);
match maybe_number {
Some(n) => println!("got {}", n),
None => println!("got nothing"),
}
Or use if let when you only care about one variant:
if let Some(n) = maybe_number {
println!("got {}", n);
}
We actually used Option in previous articles. Remember the brainfuck interpreter? Lexer::current() returned Option<u8>. Some(byte) when there was a byte to read, None when we reached the end of input. The JSON parser's Parser::current() also returned Option<&Token>. These are natural uses of Option, sometimes there's a value, sometimes there isn't, and the type system forces you to handle both cases.
Useful Methods on Option
.unwrap(): extract the value inside Some, or panic if None:
let x = Some(5);
println!("{}", x.unwrap()); // 5
let y: Option<i32> = None;
println!("{}", y.unwrap()); // PANICS!
We're trying to get away from unwrap, but you should still know what it does.
.unwrap_or(default): extract the value, or return a default if None:
let x: Option<i32> = None;
println!("{}", x.unwrap_or(0)); // 0
let y = Some(42);
println!("{}", y.unwrap_or(0)); // 42
.is_some() and .is_none(): check which variant you have:
let x = Some(5);
println!("{}", x.is_some()); // true
println!("{}", x.is_none()); // falseResult<T, E> - Dealing with Failure
Option<T> handles absence. Result<T, E> handles failure. They look similar, but serve different purposes:
Option<T>- "there might be a value, or there might be nothing" (like searching for something in a collection)Result<T, E>- "this operation might succeed with a value of type T, or it might fail with an error of type E" (like parsing a file, reading from disk, making a network request)
Result<T, E> is an enum with two variants:
enum Result<T, E> {
Ok(T), // success, contains a value of type T
Err(E), // failure, contains an error of type E
}
We actually already used Result in the grep article. Regex::new(pattern) returns Result<Regex, Error>. If the pattern is valid regex, you get Ok(regex). If it's malformed, you get Err(error). And we matched on it:
let regex = match Regex::new(pattern) {
Ok(r) => r,
Err(e) => {
eprintln!("Invalid pattern: {}", e);
std::process::exit(1);
}
};
Now we're going to go much deeper.
Pattern Matching on Result
Just like Option, you can match on Result:
match result {
Ok(value) => println!("success: {}", value),
Err(error) => println!("failure: {}", error),
}Useful Methods on Result
.unwrap() - extract the Ok value, or panic on Err. Same warning as above: we're moving away from this.
.expect("message") - like unwrap, but panics with your custom message:
let bad: Result<i32, &str> = Err("error");
bad.expect("the number should be valid"); // PANICS with "the number should be valid: error"
.unwrap_or(default) - extract the Ok value, or return a default on Err:
let bad: Result<i32, &str> = Err("oops");
println!("{}", bad.unwrap_or(0)); // 0
.is_ok() and .is_err() - check which variant you have:
let result: Result<i32, &str> = Ok(42);
println!("{}", result.is_ok()); // true
println!("{}", result.is_err()); // falseThe ? Operator
Here's a common pattern in Rust. You call a function that returns a Result. If it's Ok, you continue. If it's Err, you return the error to the caller. Written out with match, it looks like this:
fn do_something() -> Result<i32, MyError> {
let a = match step_one() {
Ok(value) => value,
Err(e) => return Err(e),
};
let b = match step_two(a) {
Ok(value) => value,
Err(e) => return Err(e),
};
let c = match step_three(b) {
Ok(value) => value,
Err(e) => return Err(e),
};
Ok(c)
}
This is called the "pyramid of doom" and it's exhausting to write and read. Rust has the ? operator to fix this:
fn do_something() -> Result<i32, MyError> {
let a = step_one()?;
let b = step_two(a)?;
let c = step_three(b)?;
Ok(c)
}
The ? operator does exactly what the match version does:
- If the
ResultisOk(value), it extracts the value and continues - If the
ResultisErr(error), it returns the error from the current function immediately
It also works on Option:
- If
Some(value), it extracts the value and continues - If
None, it returnsNonefrom the current function
There's one catch: the function you're using
?in must return aResult(orOption). You can't use?inmainunlessmainreturns aResult. We'll handle this by keeping our main logic in a separaterunfunction that returnsResult.
? Also Converts Error Types
If your function returns Result<T, ErrorA> and a function you call returns Result<T, ErrorB>, you can't just use ? directly because the error types don't match. But if ErrorA implements From<ErrorB>, then ? will automatically convert ErrorB into ErrorA for you.
We'll use this in our project our custom ConfigError type will implement From<std::io::Error> (via thiserror) so that we can use ? on file-reading operations that return io::Error.
Defining Custom Error Types
Using String as your error type works, but it's not great. You can't programmatically inspect what went wrong. You can't match on different kinds of errors. You can't add structured data like line numbers and column numbers.
The solution is to define your own error type as an enum. Let me show you how.
Creating the Error Enum
First, define the error enum with a variant for each kind of error:
enum ConfigError {
UnexpectedCharacter {
line: usize,
col: usize,
expected: String,
found: String,
},
UnterminatedString {
line: usize,
col: usize,
},
InvalidNumber {
line: usize,
col: usize,
detail: String,
},
}
Each variant carries specific data about what went wrong and where. UnexpectedCharacter tells you the line, column, what was expected, and what was found. UnterminatedString tells you where the string started. InvalidNumber gives you the position and what was wrong with the number.
This is the structural approach. When you need to display an error message, you match on the variant and format it. When you need to handle specific errors differently, you match on the variant and take different actions.
Using thiserror to Automate the Boilerplate
Writing error messages by hand is repetitive. Every time you add a variant, you have to update the match. The thiserror crate automates this with derive macros:
use thiserror::Error;
#[derive(Debug, thiserror::Error)]
enum ConfigError {
#[error("unexpected character at line {line}, column {col}: expected {expected}, found '{found}'")]
UnexpectedCharacter {
line: usize,
col: usize,
expected: String,
found: String,
},
#[error("unterminated string starting at line {line}, column {col}")]
UnterminatedString { line: usize, col: usize },
#[error("invalid number at line {line}, column {col}: {detail}")]
InvalidNumber {
line: usize,
col: usize,
detail: String,
},
}
The #[error("...")] attribute on each variant replaces the manual message formatting. The {field} placeholders inside the string are replaced with the field values, just like println!. And the #[derive(thiserror::Error)] macro generates all the necessary trait implementations for us.
Under the hood, thiserror implements two traits for our ConfigError:
- The Display trait - this controls how the error appears when you format it with
{}(likeformat!("{}", err)orprintln!("{}", err)). The#[error("...")]attribute becomes the Display implementation. - The Error trait - this marks our type as an official error in Rust's error system. It's required for interoperability. We'll learn more about traits in a future article, but for now, just know that
#[derive(thiserror::Error)]handles it for us.
The Io(#[from] std::io::Error) variant is special. The #[from] attribute tells thiserror to automatically implement From<std::io::Error> for ConfigError. This means when we call a function that returns Result<_, std::io::Error> (like fs::read_to_string), we can use ? and the io::Error will be automatically converted into ConfigError::Io.
Notice how every error variant carries a line and (often) a col. This is deliberate. When you tell a user "invalid character", the first question they'll ask is "where?". By embedding position information in the error type itself, every error message can point to the exact location in the file.
Compare this with the JSON parser from the last article, where errors just said panic!("Unexpected character") with no location information. The difference is night and day.
Now, let's start working on our project.
What is TOML?
TOML stands for Tom's Obvious Minimal Language. It's a configuration file format that's designed to be easy for humans to read and write. If you've ever used Rust, you've already seen TOML, every Cargo.toml file is a TOML file.
Here's what a TOML file looks like:
# This is a comment
host = "localhost"
port = 8080
debug = true
[server]
host = "localhost"
port = 8080
debug = true
The subset we'll support:
- Key-value pairs:
key = value - Value types: strings (
"hello"), integers (8080), floats (3.14), booleans (true/false) - Tables:
[section_name]groups key-value pairs into sections - Comments:
#lines - Dotted keys in table headers:
[server.host]creates a nested section
We won't support arrays, inline tables, or array-of-tables. This keeps the scope focused on error handling, which is the point of this article.
Project Setup
cargo new config_parser
cd config_parser
Open Cargo.toml and add thiserror:
[package]
name = "config_parser"
version = "0.1.0"
edition = "2024"
[dependencies]
thiserror = "2"
Open src/main.rs and delete everything. We'll write it from scratch.
Step 1: The Error Types
Type this into src/main.rs:
use std::fs;
#[derive(Debug, thiserror::Error)]
enum ConfigError {
#[error("unexpected character at line {line}, column {col}: expected {expected}, found '{found}'")]
UnexpectedCharacter {
line: usize,
col: usize,
expected: String,
found: String,
},
#[error("unterminated string starting at line {line}, column {col}")]
UnterminatedString { line: usize, col: usize },
#[error("invalid number at line {line}, column {col}: {detail}")]
InvalidNumber {
line: usize,
col: usize,
detail: String,
},
#[error("expected {expected} at line {line}, column {col}, found {found}")]
ExpectedToken {
line: usize,
col: usize,
expected: String,
found: String,
},
#[error("missing value for key '{key}' at line {line}, column {col}")]
MissingValue {
line: usize,
col: usize,
key: String,
},
#[error("schema violation at line {line}: key '{key}' expected type {expected}, found {found}")]
SchemaViolation {
line: usize,
key: String,
expected: String,
found: String,
},
#[error("missing required key '{key}'")]
MissingRequiredKey { key: String },
#[error("key '{key}' has value {value} which is outside range {min} to {max}")]
ValueOutOfRange {
key: String,
value: String,
min: i64,
max: i64,
},
#[error("unknown key '{key}' at line {line}")]
UnknownKey { key: String, line: usize },
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
Let me explain the key parts:
#[derive(Debug, thiserror::Error)]- this tells Rust to automatically implement theDebugtrait (for{:?}formatting) and theErrortrait. Thethiserror::Errorderive macro generates everything based on the#[error("...")]attributes.- Each
#[error("...")]attribute is the error message for that variant. The{field}placeholders are replaced with the field values, just like inprintln!. - The
Io(#[from] std::io::Error)variant is special. The#[from]attribute tells thiserror to automatically implementFrom<std::io::Error> for ConfigError. This means when we call a function that returnsResult<_, std::io::Error>(likefs::read_to_string), we can use?and theio::Errorwill be automatically converted intoConfigError::Io.
Notice how every error variant carries a line and (often) a col. When you tell a user "invalid character", the first question they'll ask is "where?". By embedding position information in the error type itself, every error message can point to the exact location in the file.
Step 2: The Token Type and Lexer
SpannedToken
In the JSON parser, the Token enum only carried the token's kind. In this project, we need each token to know where it came from. So we split the token into two parts: the kind and the location.
#[derive(Debug, Clone, PartialEq)]
enum TokenKind {
Equal,
StringLit(String),
Integer(i64),
Float(f64),
Boolean(bool),
Comment(String),
Identifier(String),
TableHeader(String),
Newline,
}
#[derive(Debug, Clone)]
struct SpannedToken {
kind: TokenKind,
line: usize,
col: usize,
}
TokenKindis the "what kind of token is this" enum.Equalis=,StringLit("hello")is a quoted string,Integer(42)is a number,Boolean(true)is a boolean,TableHeader("server")is[server],Newlineis a line break.SpannedTokenbundles the kind with its position in the source file (lineandcol). When we encounter an error, we can pull the position from the token that caused it.
Compare this with the JSON parser's Token enum. The key difference: our SpannedToken knows where it came from. The JSON parser's tokens had no position information, which is why its error messages were just "unexpected character" with no location.
The Lexer
struct Lexer {
input: Vec<u8>,
pos: usize,
line: usize,
col: usize,
}
This is very similar to the JSON parser's lexer, but with one crucial addition: line and col tracking. Every time advance() is called, we check if the current byte is a newline. If it is, we increment line and reset col to 1. Otherwise, we just increment col. This gives us exact position information for every byte in the input.
The tokenize Method
fn tokenize(&mut self) -> Result<Vec<SpannedToken>, ConfigError> {
Notice the return type: Result<Vec<SpannedToken>, ConfigError>. In the JSON parser, tokenize returned Vec<Token> and just paniced on errors. Here, it returns a Result. If tokenization succeeds, you get Ok(tokens). If it fails, you get Err(ConfigError) with a specific variant telling you exactly what went wrong and where.
The body of tokenize is similar to the JSON parser, but every error case returns Err(ConfigError::...) instead of panic!(). Here's the key difference: in the JSON parser, an unexpected character caused panic!("Unexpected character: {}", c as char). In our project, it returns Err(ConfigError::UnexpectedCharacter { line, col, expected, found }) with the exact position built in.
Reading Strings
fn read_string(&mut self) -> Result<(String, usize, usize), ConfigError> {
let start_line = self.line;
let start_col = self.col;
self.advance();
let mut result = String::new();
loop {
match self.current() {
None => {
return Err(ConfigError::UnterminatedString {
line: start_line,
col: start_col,
});
}
Some(b'"') => {
self.advance();
return Ok((result, start_line, start_col));
}
Some(b'\\') => {
self.advance();
match self.current() {
Some(b'"') => { result.push('"'); self.advance(); }
Some(b'\\') => { result.push('\\'); self.advance(); }
Some(b'n') => { result.push('\n'); self.advance(); }
Some(b't') => { result.push('\t'); self.advance(); }
Some(b'r') => { result.push('\r'); self.advance(); }
_ => {
let found = match self.current() {
Some(b) => (b as char).to_string(),
None => String::from("end of input"),
};
return Err(ConfigError::UnexpectedCharacter {
line: self.line,
col: self.col,
expected: "valid escape sequence".to_string(),
found,
});
}
}
}
Some(c) => {
result.push(c as char);
self.advance();
}
}
}
}
In the JSON parser, unterminated strings caused panic!("Unterminated string"). Now they return Err(ConfigError::UnterminatedString { line, col }) with the exact position where the string started. The error message will say "unterminated string starting at line 3, column 12" instead of just crashing.
Reading Numbers
fn read_number(&mut self) -> Result<(TokenKind, usize, usize), ConfigError> {
let start_line = self.line;
let start_col = self.col;
let mut s = String::new();
let mut has_dot = false;
if let Some(b'-') = self.current() {
s.push('-');
self.advance();
}
loop {
match self.current() {
Some(c @ b'0'..=b'9') => {
s.push(c as char);
self.advance();
}
Some(b'.') => {
if has_dot {
return Err(ConfigError::InvalidNumber {
line: start_line,
col: start_col,
detail: "multiple decimal points".to_string(),
});
}
has_dot = true;
s.push('.');
self.advance();
}
_ => break,
}
}
if has_dot {
let value: f64 = match s.parse() {
Ok(v) => v,
Err(_) => {
return Err(ConfigError::InvalidNumber {
line: start_line,
col: start_col,
detail: format!("cannot parse '{}' as float", s),
});
}
};
Ok((TokenKind::Float(value), start_line, start_col))
} else {
let value: i64 = match s.parse() {
Ok(v) => v,
Err(_) => {
return Err(ConfigError::InvalidNumber {
line: start_line,
col: start_col,
detail: format!("cannot parse '{}' as integer", s),
});
}
};
Ok((TokenKind::Integer(value), start_line, start_col))
}
}
Two important things here. First, multiple decimal points like 3.14.159 now return Err(ConfigError::InvalidNumber { ... detail: "multiple decimal points" }) instead of panicking.
Second, the .parse() calls use match instead of .unwrap(). When you call "abc".parse::<i64>(), it returns Result<i64, ParseIntError>. We can't use ? here because ParseIntError is not our ConfigError type. Instead, we match on the result and construct our own error with the position information. This is a common pattern: when a library returns an error type that doesn't match yours, you match on their result and create your own error.
Step 3: The TomlValue Type
#[derive(Debug, Clone, PartialEq)]
enum TomlValue {
String(String),
Integer(i64),
Float(f64),
Boolean(bool),
Table(Vec<(String, TomlValue)>),
}
This is similar to the JsonValue enum from the JSON parser, but simpler because TOML doesn't have arrays or null. The Table variant uses a Vec<(String, TomlValue)> just like the JSON parser's Object, each key-value pair is a tuple.
To display a TomlValue, we write a standalone function, just like we did for JsonValue in the previous article:
fn display(value: &TomlValue) -> String {
match value {
TomlValue::String(s) => format!("\"{}\"", s),
TomlValue::Integer(n) => format!("{}", n),
TomlValue::Float(n) => format!("{}", n),
TomlValue::Boolean(b) => format!("{}", b),
TomlValue::Table(pairs) => {
let mut result = String::from("{");
let mut first = true;
for (k, v) in pairs {
if !first {
result.push_str(", ");
}
result.push_str(&format!("{}: {}", k, display(v)));
first = false;
}
result.push('}');
result
}
}
}
This is exactly the same pattern we used for the JSON parser, a recursive function that pattern matches on each variant and builds a string.
Step 4: The Parser
The parser takes a Vec<SpannedToken> and turns it into a TomlValue. It uses the same cursor pattern as the lexer, a current position that advances as tokens are consumed.
struct Parser {
tokens: Vec<SpannedToken>,
pos: usize,
}The Main Parse Method
The parser handles three kinds of top-level items:
- Comments and newlines - skip them
- Table headers like
[server]- collect all following key-value pairs into a sub-table - Key-value pairs - parse key, equal sign, value
Every place the JSON parser had panic!(), we have Err(ConfigError::...). Even better, every error includes the line and column from the token. When a key is followed by something other than =, the error says "expected = at line 5, column 12, found Integer" instead of crashing with no context.
The ? operator is used throughout: self.parse_value()? propagates errors automatically. If parse_value fails, the error bubbles up through parse and up to the caller effortlessly.
Parse Value
fn parse_value(&mut self) -> Result<TomlValue, ConfigError> {
let token = self.current();
match token {
Some(t) => match &t.kind {
TokenKind::StringLit(s) => {
let val = TomlValue::String(s.clone());
self.advance();
Ok(val)
}
TokenKind::Integer(n) => {
let val = TomlValue::Integer(*n);
self.advance();
Ok(val)
}
TokenKind::Float(n) => {
let val = TomlValue::Float(*n);
self.advance();
Ok(val)
}
TokenKind::Boolean(b) => {
let val = TomlValue::Boolean(*b);
self.advance();
Ok(val)
}
_ => Err(ConfigError::ExpectedToken {
line: t.line,
col: t.col,
expected: "value (string, integer, float, or boolean)".to_string(),
found: format!("{:?}", t.kind),
}),
},
None => Err(ConfigError::ExpectedToken {
line: 0,
col: 0,
expected: "value".to_string(),
found: "end of input".to_string(),
}),
}
}
Clean and straightforward. If we have a token that's a valid value type, we return it. If not, we return a specific error saying what we expected and what we found instead.
Step 5: The Schema
The schema defines what keys are expected in the config file, what types they should be, and whether they're required. The schema is itself a TOML file, parsed by our own parser.
enum FieldType {
String,
Integer,
Float,
Boolean,
}
struct FieldSchema {
field_type: FieldType,
required: bool,
default: Option<TomlValue>,
min: Option<i64>,
max: Option<i64>,
}
impl FieldType {
fn from_str(s: &str) -> Option<FieldType> {
match s {
"string" => Some(FieldType::String),
"integer" => Some(FieldType::Integer),
"float" => Some(FieldType::Float),
"boolean" => Some(FieldType::Boolean),
_ => None,
}
}
fn name(&self) -> &'static str {
match self {
FieldType::String => "string",
FieldType::Integer => "integer",
FieldType::Float => "float",
FieldType::Boolean => "boolean",
}
}
}
#[derive(Debug, Clone)]
struct FieldSchema {
field_type: FieldType,
required: bool,
default: Option<TomlValue>,
min: Option<i64>,
max: Option<i64>,
}
type Schema = Vec<(String, FieldSchema)>;
FieldType - the expected type for a config key.
FieldSchema - the full validation spec for a single key.
required - if true, the key must be present (unless it has a default).
default - a value to use if the key is missing.
min and max - for integers, the valid range.
Schema - a list of key names and their validation rules.
FieldType::from_str converts a string like "integer" into a FieldType variant (or None if the string isn't a valid type name). FieldType::name converts a variant back into a string for error messages.
The schema TOML file looks like this:
[server.host]
type = "string"
required = true
[server.port]
type = "integer"
min = 1
max = 65535
required = true
When our parser processes this, [server.host] becomes a TableHeader("server.host"), and the key-value pairs inside it become entries in a sub-table. The parse_schema function walks this sub-table and extracts the validation rules:
fn parse_schema(value: &TomlValue) -> Result<Schema, ConfigError> {
let mut schema = Vec::new();
let table = match value {
TomlValue::Table(pairs) => pairs,
_ => {
return Err(ConfigError::UnexpectedCharacter {
line: 0,
col: 0,
expected: "table".to_string(),
found: "non-table value".to_string(),
});
}
};
for (key, val) in table {
let inner = match val {
TomlValue::Table(pairs) => pairs,
_ => continue,
};
let mut field_type = FieldType::String;
let mut required = false;
let mut default_val: Option<TomlValue> = None;
let mut min_val: Option<i64> = None;
let mut max_val: Option<i64> = None;
for (field_name, field_val) in inner {
match field_name.as_str() {
"type" => {
let type_str = match field_val {
TomlValue::String(s) => s.clone(),
_ => String::new(),
};
let ft = FieldType::from_str(&type_str);
match ft {
Some(t) => field_type = t,
None => {
return Err(ConfigError::SchemaViolation {
line: 0,
key: key.clone(),
expected: "valid type (string, integer, float, boolean)".to_string(),
found: type_str,
});
}
}
}
"required" => {
required = match field_val {
TomlValue::Boolean(b) => *b,
_ => false,
};
}
"default" => {
default_val = Some(field_val.clone());
}
"min" => {
min_val = match field_val {
TomlValue::Integer(n) => Some(*n),
_ => None,
};
}
"max" => {
max_val = match field_val {
TomlValue::Integer(n) => Some(*n),
_ => None,
};
}
_ => {}
}
}
schema.push((
key.clone(),
FieldSchema {
field_type,
required,
default: default_val,
min: min_val,
max: max_val,
},
));
}
Ok(schema)
}
This function takes the parsed schema TomlValue (which is a Table of sub-tables) and walks each entry. For each key like server.port, it reads the type, required, default, min, and max fields from the sub-table. If the type string isn't one of our known types, it returns a SchemaViolation error.
The flatten_table helper is also needed, it converts the nested TomlValue::Table into a flat list of dotted keys like server.host, server.port, etc.:
fn flatten_table(table: &TomlValue, prefix: &str) -> Vec<(String, TomlValue)> {
let pairs = match table {
TomlValue::Table(pairs) => pairs,
_ => return vec![],
};
let mut result = Vec::new();
for (key, value) in pairs {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
match value {
TomlValue::Table(_) => {
let nested = flatten_table(value, &full_key);
for item in nested {
result.push(item);
}
}
_ => {
result.push((full_key, value.clone()));
}
}
}
result
}
And a small toml_type_name helper that maps a TomlValue variant to a human-readable type name, used in schema violation error messages:
fn toml_type_name(value: &TomlValue) -> &'static str {
match value {
TomlValue::String(_) => "string",
TomlValue::Integer(_) => "integer",
TomlValue::Float(_) => "float",
TomlValue::Boolean(_) => "boolean",
TomlValue::Table(_) => "table",
}
}Step 6: Schema Validation
The validate function takes a parsed config, a schema, and the original source text. It checks every key in the config against the schema and collects all errors.
fn validate(config: &TomlValue, schema: &Schema, source: &str) -> Result<(), Vec<ConfigError>> {
let mut errors = Vec::new();
let flat_config = flatten_table(config, "");
for (key, field) in schema {
let mut found_value: Option<&TomlValue> = None;
for (k, v) in &flat_config {
if k == key {
found_value = Some(v);
break;
}
}
match found_value {
None => {
if field.required {
match &field.default {
Some(_) => {}
None => {
errors.push(ConfigError::MissingRequiredKey { key: key.clone() });
}
}
}
}
Some(value) => {
let type_matches = match (&field.field_type, value) {
(FieldType::String, TomlValue::String(_)) => true,
(FieldType::Integer, TomlValue::Integer(_)) => true,
(FieldType::Float, TomlValue::Float(_)) => true,
(FieldType::Float, TomlValue::Integer(_)) => true,
(FieldType::Boolean, TomlValue::Boolean(_)) => true,
_ => false,
};
if !type_matches {
let line = find_line_for_key(source, key);
errors.push(ConfigError::SchemaViolation {
line,
key: key.clone(),
expected: field.field_type.name().to_string(),
found: toml_type_name(value).to_string(),
});
}
if let (TomlValue::Integer(n), Some(min)) = (value, field.min) {
if *n < min {
errors.push(ConfigError::ValueOutOfRange {
key: key.clone(),
value: n.to_string(),
min,
max: field.max.unwrap_or(0),
});
}
}
if let (TomlValue::Integer(n), Some(max)) = (value, field.max) {
if *n > max {
errors.push(ConfigError::ValueOutOfRange {
key: key.clone(),
value: n.to_string(),
min: field.min.unwrap_or(0),
max,
});
}
}
}
}
}
for (key, _) in &flat_config {
let mut is_known = false;
for (k, _) in schema {
if k == key {
is_known = true;
break;
}
}
if !is_known {
let line = find_line_for_key(source, key);
errors.push(ConfigError::UnknownKey {
key: key.clone(),
line,
});
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
validate returns Result<(), Vec<ConfigError>>, not Result<(), ConfigError>. It collects all errors, not just the first one. This is important for a config validator — you want to see everything that's wrong, not fix one error, re-run, fix another, re-run, etc.
The flatten_table helper converts the nested TomlValue::Table into a flat list of dotted keys like server.host, server.port, etc. This makes it easy to match against the schema keys.
The find_line_for_key helper searches the original TOML source text for a key and returns its line number. It strips the section prefix from dotted keys (turning server.host into just host) and scans for lines matching key =:
fn find_line_for_key(source: &str, target_key: &str) -> usize {
let short_key = if target_key.contains('.') {
let mut parts = target_key.rsplit('.');
match parts.next() {
Some(k) => k,
None => target_key,
}
} else {
target_key
};
for (i, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.starts_with('[') || trimmed.is_empty() {
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let key_part = trimmed[..eq_pos].trim();
if key_part == short_key {
return i + 1;
}
}
}
0
}
It skips comments, section headers, and empty lines, then looks for lines where the part before = matches the key. If it can't find the key, it returns 0 (which format_error treats as "no line information available").
Step 7: Displaying Errors
fn format_error(err: &ConfigError, source: &str) -> String {
let line_num = match err {
ConfigError::UnexpectedCharacter { line, .. } => *line,
ConfigError::UnterminatedString { line, .. } => *line,
ConfigError::InvalidNumber { line, .. } => *line,
ConfigError::ExpectedToken { line, .. } => *line,
ConfigError::MissingValue { line, .. } => *line,
ConfigError::SchemaViolation { line, .. } => *line,
ConfigError::UnknownKey { line, .. } => *line,
_ => 0,
};
if line_num == 0 {
return format!("Error: {}", err);
}
let lines: Vec<&str> = source.lines().collect();
let line_content = match lines.get(line_num - 1) {
Some(l) => *l,
None => "",
};
format!(
"Error: {}\n |\n{:>2} | {}\n |",
err,
line_num,
line_content
)
}
This function takes an error and the original source text, finds the offending line, and formats it like:
Error: schema violation at line 3: key 'server.host' expected type string, found integer
|
3 | host = 1234
|
The match err { ... } extracts the line number from whichever error variant it is. All the variants that carry a line field get their line number extracted. Variants like MissingRequiredKey that don't have a specific line get 0, in which case we just print the error message without a source line.
Step 8: Main Function and Demo
fn run(config_path: &str, schema_path: &str) -> Result<(), Vec<ConfigError>> {
let config_source = match fs::read_to_string(config_path) {
Ok(s) => s,
Err(e) => return Err(vec![ConfigError::from(e)]),
};
let schema_source = match fs::read_to_string(schema_path) {
Ok(s) => s,
Err(e) => return Err(vec![ConfigError::from(e)]),
};
let mut config_lexer = Lexer::new(&config_source);
let config_tokens = match config_lexer.tokenize() {
Ok(tokens) => tokens,
Err(e) => return Err(vec![e]),
};
let mut config_parser = Parser::new(config_tokens);
let config_value = match config_parser.parse() {
Ok(v) => v,
Err(e) => return Err(vec![e]),
};
let mut schema_lexer = Lexer::new(&schema_source);
let schema_tokens = match schema_lexer.tokenize() {
Ok(tokens) => tokens,
Err(e) => return Err(vec![e]),
};
let mut schema_parser = Parser::new(schema_tokens);
let schema_value = match schema_parser.parse() {
Ok(v) => v,
Err(e) => return Err(vec![e]),
};
let schema = match parse_schema(&schema_value) {
Ok(s) => s,
Err(e) => return Err(vec![e]),
};
validate(&config_value, &schema, &config_source)?;
println!("Config is valid!");
println!();
println!("Parsed config:");
for (key, value) in flatten_table(&config_value, "") {
println!(" {} = {}", key, display(&value));
}
Ok(())
}
This is the beauty of proper error handling. Look at run(): it's a pipeline of operations, each of which can fail. Each match on a Result handles the error case explicitly. For cases where we want to convert a single error into a Vec of errors, we wrap it with vec![e].
Notice we use match here instead of ?. That's because run returns Result<(), Vec<ConfigError>>, but most of the inner calls return Result<_, ConfigError> (a single error, not a Vec). The ? operator only works when the error types match, so we explicitly match and wrap single errors into a Vec where needed.
The ConfigError::from(e) on the fs::read_to_string error lines works because we defined Io(#[from] std::io::Error) in our error enum. The #[from] attribute generated a From<std::io::Error> for ConfigError implementation, so ConfigError::from(e) converts an io::Error into our ConfigError::Io variant.
The main function reads the file paths from command-line arguments (or defaults to the example files), calls run, and handles any errors:
fn main() {
let args: Vec<String> = std::env::args().collect();
let (config_path, schema_path) = if args.len() >= 3 {
(args[1].clone(), args[2].clone())
} else {
(String::from("examples/valid_config.toml"), String::from("examples/schema.toml"))
};
match run(&config_path, &schema_path) {
Ok(()) => {}
Err(errors) => {
let source = match fs::read_to_string(&config_path) {
Ok(s) => s,
Err(_) => String::new(),
};
for err in &errors {
eprintln!("{}", format_error(err, &source));
eprintln!();
}
std::process::exit(1);
}
}
}
If run returns Ok(()), we're done. If it returns Err(errors), we load the source text (so we can point to specific lines), format each error with format_error, print them to stderr, and exit with code 1.
Running with a Valid Config
Create examples/valid_config.toml:
# Server configuration
[server]
host = "localhost"
port = 8080
debug = true
[database]
url = "postgres://localhost/mydb"
max_connections = 10
Run it:
cargo run -- examples/valid_config.toml examples/schema.toml
Output:
Config is valid!
Parsed config:
server.host = "localhost"
server.port = 8080
server.debug = true
database.url = "postgres://localhost/mydb"
database.max_connections = 10Running with an Invalid Config
Create examples/invalid_config.toml:
# Config with multiple errors
[server]
host = 1234
port = 99999
debug = "yes"
[database]
url = "postgres://localhost/mydb"
max_connections = 200
Run it:
cargo run -- examples/invalid_config.toml examples/schema.toml
Output:
Error: schema violation at line 3: key 'server.host' expected type string, found integer
|
3 | host = 1234
|
Error: key 'server.port' has value 99999 which is outside range 1 to 65535
Error: schema violation at line 5: key 'server.debug' expected type boolean, found string
|
5 | debug = "yes"
|
Error: key 'database.max_connections' has value 200 which is outside range 1 to 100
All four errors are reported at once. No panicking, no crashing, no vague messages. Every error tells you exactly what's wrong and where.
Conclusion
In this post, we replaced every panic! and unwrap with proper Result based error handling. We defined a custom ConfigError enum with rich error variants carrying line numbers and context. We used thiserror to avoid writing boilerplate code. We used the ? operator to propagate errors effortlessly. And we built a validator that collects all errors and displays them with source lines, not just the first one.
In the next article, we'll learn about generics, traits, and lifetimes and build a Generic LRU Cache. See you soon.