rust 132 lines · 3 tabs

Parsing a Layered TOML Config into Typed Sections with Serde and Defaults

Shared by codesnips Aug 2026
3 tabs
use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    pub server: ServerConfig,
    pub database: DatabaseConfig,
    #[serde(default)]
    pub logging: LoggingConfig,
}

#[derive(Debug, Deserialize)]
pub struct ServerConfig {
    pub host: String,
    #[serde(default = "default_port")]
    pub port: u16,
}

#[derive(Debug, Deserialize)]
pub struct DatabaseConfig {
    pub url: String,
    #[serde(default = "default_max_connections")]
    pub max_connections: u32,
}

#[derive(Debug, Default, Deserialize)]
pub struct LoggingConfig {
    #[serde(default)]
    pub level: LogLevel,
}

#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
    Debug,
    #[default]
    Info,
    Warn,
    Error,
}

fn default_port() -> u16 {
    8080
}

fn default_max_connections() -> u32 {
    10
}

impl Config {
    pub fn validate(&self) -> Result<(), String> {
        if self.database.url.trim().is_empty() {
            return Err("database.url must not be empty".into());
        }
        if self.database.max_connections == 0 {
            return Err("database.max_connections must be greater than 0".into());
        }
        Ok(())
    }
}
3 files · rust Explain with highlit

This snippet models the common task of loading application configuration from a TOML file into strongly-typed Rust structs, layering environment overrides on top, and validating the result before the program uses it. The value of splitting it into files is that the config schema stays a pure data description while loading, merging, and error handling live in a dedicated loader.

In config.rs, each section of the file becomes its own struct: ServerConfig, DatabaseConfig, and LoggingConfig, aggregated by the top-level Config. #[derive(Deserialize)] lets serde map TOML tables onto these types by field name, so a [server] table populates ServerConfig. Optional keys use #[serde(default = "...")] pointing at small functions like default_port, which means a missing key falls back to a sensible value instead of failing. #[serde(deny_unknown_fields)] on Config turns typos in section names into hard errors rather than silently ignored config — a deliberate trade-off favoring strictness. The LogLevel enum with #[serde(rename_all = "lowercase")] shows how a constrained set of string values maps to a real enum, so invalid levels are rejected at parse time.

The validate method covers rules serde cannot express, such as max_connections being non-zero; this separation keeps deserialization about shape and validation about semantics.

In loader.rs, ConfigError uses thiserror to unify IO, parse, and validation failures into one type with #[from] conversions, so ? propagates cleanly. Config::load reads the file, calls toml::from_str, applies apply_env_overrides to let variables like APP__SERVER__PORT win over file values, then runs validate. Reading the file separately from parsing produces clearer error messages that name the path.

In main.rs, the entry point simply calls Config::load and prints a friendly message on failure, exiting non-zero. This pattern is worth reaching for whenever config correctness matters at startup: failing fast with a precise error beats discovering a bad port or empty database URL deep in a request handler. The main pitfall is over-strictness locking out valid future keys, which deny_unknown_fields should be applied to consciously.


Related snips

Share this code

Here's the card — post it anywhere.

Parsing a Layered TOML Config into Typed Sections with Serde and Defaults — share card
Link copied