DEV Community

Naveen Karasu
Naveen Karasu

Posted on

Stop Calling .unwrap() on Your Config Loader (Rust File IO + serde JSON)

Day 25/90: The AppConfig Loader That Panicked on a Trailing Comma

90 Day Rust Challenge: Zero to Production

The first time a user shipped my tool a config file with a stray trailing comma, the whole binary went down. Not a message, not a fallback — a panic and a backtrace. The cause was a line I wrote without thinking, because it was a demo and demos always have valid input:

fn read_config(p: &str) -> AppConfig {
    let bytes = std::fs::read_to_string(p).unwrap();
    let parsed: AppConfig = serde_json::from_str(&bytes).unwrap();
    parsed
}
Enter fullscreen mode Exit fullscreen mode

Two .unwrap() calls, two ways to crash. The first panics on a missing or unreadable file. The second panics on JSON that is invalid or does not match my struct.

Why these calls return Result

std::fs::read_to_string returns io::Result<String>, which is Result<String, std::io::Error>. serde_json::from_str returns a Result too. Both signatures are telling you, in the type, that the operation can fail — because reading a file you did not write, and parsing text you did not generate, can genuinely fail. Calling .unwrap() throws away the Err half and turns a normal error into a panic.

The fix is the ? operator. It returns the error to the caller instead of crashing:

use std::fs;

fn load_config(path: &str) -> Result<AppConfig, Box<dyn std::error::Error>> {
    let raw = fs::read_to_string(path)?;
    let config: AppConfig = serde_json::from_str(&raw)?;
    Ok(config)
}
Enter fullscreen mode Exit fullscreen mode

In a real loader, those ? conversions would land in the thiserror enum from yesterday's post, so each error becomes a named variant instead of a stringly-typed blob.

The AppConfig struct and two serde attributes

You describe the shape once with two derives and serde generates the read and write code:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct AppConfig {
    name: String,
    host: String,
    port: u16,
    #[serde(default)]
    verbose: bool,
    #[serde(default)]
    retries: u32,
    #[serde(default)]
    max_conns: u32,
}
Enter fullscreen mode Exit fullscreen mode

#[serde(default)] makes verbose optional. A config that omits it deserializes fine and the field becomes false. Without that attribute, a missing field is a hard missing field error. That one line is how you declare "optional" right on the field.

Round-trip: read, mutate, write

The full loop — read, deserialize, mutate, serialize with to_string_pretty, write — with ? on every fallible step:

use std::fs;
use std::error::Error;

fn bump_port(path: &str) -> Result<(), Box<dyn Error>> {
    let text = fs::read_to_string(path)?;
    let mut config: AppConfig = serde_json::from_str(&text)?;
    config.port += 1;
    let out = serde_json::to_string_pretty(&config)?;
    fs::write(path, out)?;
    Ok(())
}

fn main() {
    if let Err(e) = bump_port("config.json") {
        eprintln!("could not process config: {e}");
        std::process::exit(1);
    }
}
Enter fullscreen mode Exit fullscreen mode

to_string_pretty writes the file with indentation so it diffs cleanly. Feed this a malformed file and you get could not process config: expected ... on stderr plus exit code 1, instead of a backtrace.

Streaming large files

from_str loads the entire file into a String, then parses it — fine for a config, wasteful for a big data file. from_reader over a BufReader parses straight from the stream:

use std::fs::File;
use std::io::BufReader;

fn load_big(path: &str) -> Result<AppConfig, Box<dyn std::error::Error>> {
    let file = File::open(path)?;
    let reader = BufReader::with_capacity(64 * 1024, file);
    let config: AppConfig = serde_json::from_reader(reader)?;
    Ok(config)
}
Enter fullscreen mode Exit fullscreen mode

Use from_str when you already hold a string; use from_reader over a BufReader when you are reading off disk and the file might be large.

The rule I kept

.unwrap() on fs and serde_json calls is not error handling. It is a deferred panic that fires on the first imperfect input — and there is always a first imperfect input. Reading and parsing both return Result. The ? operator turns "the program died" into "the function returned an error the caller can read," and it costs one character.

Top comments (0)