
Rust Code Examples: Building a Type-Safe Configuration Loader with `serde` and `toml`
Why a typed configuration loader is worth the effort
A configuration loader should do more than read a file. It should:
- map raw configuration data into domain-specific types
- fail fast when required values are missing or invalid
- support defaults for optional settings
- keep parsing logic separate from business logic
- make configuration changes easy to test
Rust is a strong fit for this task because its type system forces you to define what your application expects. That means fewer runtime surprises and clearer error messages when a config file is wrong.
A common pattern is:
- define a
Configstruct - deserialize a file into that struct
- validate the result
- use the config throughout the application
Project setup
This example uses the following crates:
serdefor serialization and deserializationtomlfor parsing TOML configuration filesthiserrorfor ergonomic custom errors
Add them to Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "0.8"
thiserror = "1"TOML is a good choice for configuration because it is human-readable, supports nested structures, and maps cleanly to Rust structs.
Designing the configuration model
Suppose you are building a background worker service. It needs:
- a server bind address
- a log level
- retry settings for outbound requests
- a database section
Here is a realistic configuration model:
use serde::Deserialize;
use std::net::SocketAddr;
#[derive(Debug, Deserialize)]
pub struct Config {
pub server: ServerConfig,
pub logging: LoggingConfig,
pub retry: RetryConfig,
pub database: DatabaseConfig,
}
#[derive(Debug, Deserialize)]
pub struct ServerConfig {
pub bind_addr: SocketAddr,
}
#[derive(Debug, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
}
#[derive(Debug, Deserialize)]
pub struct RetryConfig {
#[serde(default = "default_retry_attempts")]
pub attempts: u32,
#[serde(default = "default_retry_delay_ms")]
pub delay_ms: u64,
}
#[derive(Debug, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
#[serde(default = "default_pool_size")]
pub pool_size: u32,
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_retry_attempts() -> u32 {
3
}
fn default_retry_delay_ms() -> u64 {
250
}
fn default_pool_size() -> u32 {
10
}Why this structure works
Each section is explicit and self-documenting. The SocketAddr type ensures the bind address is valid at parse time, while serde(default = "...") provides sensible fallback values for optional fields.
This is better than parsing strings manually because invalid values are rejected immediately. For example, "bind_addr": "localhost" would fail, while "127.0.0.1:8080" would succeed.
Loading a config file from disk
Now implement a loader that reads a TOML file and deserializes it into Config.
use std::fs;
use std::path::Path;
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse config file: {0}")]
Parse(#[from] toml::de::Error),
}
pub fn load_config(path: impl AsRef<Path>) -> Result<Config, ConfigError> {
let contents = fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}This function is intentionally small. It does one job: load and parse. That separation keeps the code easy to test and reuse.
Example configuration file
Create a file named config.toml:
[server]
bind_addr = "127.0.0.1:8080"
[logging]
level = "debug"
[retry]
attempts = 5
delay_ms = 500
[database]
url = "postgres://localhost/app"
pool_size = 16If pool_size were omitted, the default value of 10 would be used.
Adding validation after deserialization
Deserialization checks syntax and type compatibility, but it does not always enforce business rules. For example, a retry delay of 0 may be technically valid but semantically useless.
Add a validation method to Config:
impl Config {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.retry.attempts == 0 {
return Err(ConfigError::Validation(
"retry.attempts must be greater than 0".into(),
));
}
if self.retry.delay_ms < 50 {
return Err(ConfigError::Validation(
"retry.delay_ms must be at least 50".into(),
));
}
if self.database.pool_size == 0 {
return Err(ConfigError::Validation(
"database.pool_size must be greater than 0".into(),
));
}
Ok(())
}
}Update the error type:
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse config file: {0}")]
Parse(#[from] toml::de::Error),
#[error("invalid configuration: {0}")]
Validation(String),
}Then call validation from the loader:
pub fn load_config(path: impl AsRef<Path>) -> Result<Config, ConfigError> {
let contents = fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
config.validate()?;
Ok(config)
}This pattern is especially useful in production services where startup should fail loudly if the configuration is unsafe or incomplete.
Using the config in application code
Once loaded, the config becomes a normal Rust value that you can pass into your application.
fn main() -> Result<(), ConfigError> {
let config = load_config("config.toml")?;
println!("Starting server on {}", config.server.bind_addr);
println!("Log level: {}", config.logging.level);
println!("Database URL: {}", config.database.url);
Ok(())
}In a real application, you would likely initialize logging, database pools, and network listeners using these values.
A useful practice is to keep the config immutable after startup. If runtime changes are needed, load them through a separate mechanism such as a watched file, admin API, or feature-specific settings store.
Supporting environment overrides
File-based configuration is convenient, but production deployments often need overrides from environment variables. A common compromise is:
- use a file for base configuration
- override selected values from the environment
- validate the merged result
Here is a simple example that overrides the log level:
use std::env;
pub fn apply_env_overrides(mut config: Config) -> Config {
if let Ok(level) = env::var("APP_LOG_LEVEL") {
config.logging.level = level;
}
config
}You can integrate this into the loader:
pub fn load_config(path: impl AsRef<Path>) -> Result<Config, ConfigError> {
let contents = fs::read_to_string(path)?;
let mut config: Config = toml::from_str(&contents)?;
config = apply_env_overrides(config);
config.validate()?;
Ok(config)
}When to use overrides
| Source | Best for | Strengths | Tradeoffs |
|---|---|---|---|
| TOML file | Base application settings | Easy to version and review | Requires file distribution |
| Environment variables | Deployment-specific values | Works well in containers and CI | Harder to discover and document |
| Command-line flags | One-off runtime changes | Convenient for local runs | Not ideal for complex structured data |
For many applications, a layered approach is the most practical.
Testing the loader
Configuration code should be tested like any other critical startup path. A bad config parser can break deployments just as easily as a broken database migration.
Here is a unit test that verifies successful parsing:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_config() {
let input = r#"
[server]
bind_addr = "127.0.0.1:8080"
[logging]
level = "info"
[retry]
attempts = 3
delay_ms = 250
[database]
url = "postgres://localhost/app"
"#;
let config: Config = toml::from_str(input).expect("config should parse");
config.validate().expect("config should be valid");
assert_eq!(config.retry.attempts, 3);
assert_eq!(config.database.pool_size, 10);
}
#[test]
fn rejects_invalid_retry_attempts() {
let input = r#"
[server]
bind_addr = "127.0.0.1:8080"
[logging]
level = "info"
[retry]
attempts = 0
delay_ms = 250
[database]
url = "postgres://localhost/app"
"#;
let config: Config = toml::from_str(input).expect("config should parse");
let err = config.validate().unwrap_err();
match err {
ConfigError::Validation(message) => {
assert!(message.contains("retry.attempts"));
}
_ => panic!("expected validation error"),
}
}
}Testing config parsing is valuable because it catches mistakes in defaults, field names, and validation rules before they reach production.
Best practices for maintainable config code
A few design choices make configuration code easier to evolve:
Keep parsing and validation separate
Parsing answers “Can this file be decoded?” Validation answers “Should the application accept these values?” That separation makes errors clearer and testing simpler.
Prefer typed fields over raw strings
Use SocketAddr, u16, Duration, or custom enums instead of String when possible. Typed fields reduce downstream parsing and make invalid states harder to represent.
Use defaults intentionally
Defaults should reflect safe, predictable behavior. Avoid silent defaults for values that are critical to correctness, such as database URLs or authentication secrets.
Avoid overloading the config file
If a value changes frequently at runtime, it may not belong in a static config file. Use the config for startup behavior and keep dynamic state elsewhere.
Document the schema
Even with strong typing, users still need to know what each field means. A short example config file is often the best documentation.
Extending the pattern
Once the basic loader is in place, you can extend it in several directions:
- support multiple config files for different environments
- merge defaults, file values, and environment overrides
- add custom deserializers for
DurationorUrl - load secrets from a dedicated secret manager instead of plain text files
- expose a
--configCLI flag to choose the file path
For example, if you want to parse a duration like "500ms" or "2s", you can implement a custom deserializer or use a helper crate. That keeps the config format ergonomic without sacrificing type safety.
Summary
A typed configuration loader gives Rust applications a strong foundation:
- configuration is parsed once and validated early
- invalid values are rejected before the application starts
- business logic works with structured data instead of raw strings
- tests can cover parsing and validation independently
This approach scales well from small tools to production services. It is simple enough to implement directly, but flexible enough to support layered configuration and deployment-specific overrides.
