
Building a Typed Command-Line Argument Parser in Rust with `clap` and Custom Validation
Why typed CLI parsing matters
A CLI parser should do more than split strings into fields. It should:
- validate required inputs early
- convert raw strings into meaningful types
- provide actionable error messages
- keep parsing logic separate from business logic
- make invalid states unrepresentable where possible
Rust’s enums, structs, and Result types make this a natural fit. Instead of passing around String values like "debug" or "30", you can parse them into LogLevel or u16 before the rest of your application runs.
This approach is especially useful when your tool has:
- multiple subcommands
- mutually exclusive options
- numeric ranges or filesystem constraints
- environment-variable overrides
- defaults that depend on runtime context
Project setup
Create a new binary project:
cargo new typed-cli
cd typed-cliAdd clap with derive support to Cargo.toml:
[dependencies]
clap = { version = "4", features = ["derive"] }We will build a small backup utility with these features:
- a
backupsubcommand - a
restoresubcommand - typed options for compression level and output directory
- custom validation for paths and numeric ranges
- optional environment-variable fallback
Modeling the CLI with structs and enums
Start by defining the command structure. clap can derive parsing code directly from Rust types.
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(name = "typed-cli", version, about = "A typed backup utility")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Backup(BackupArgs),
Restore(RestoreArgs),
}
#[derive(Debug, Parser)]
struct BackupArgs {
/// Source directory to archive
#[arg(short, long)]
source: PathBuf,
/// Destination archive path
#[arg(short, long)]
output: PathBuf,
/// Compression level from 0 to 9
#[arg(short, long, default_value_t = 6)]
compression: u8,
/// Verbosity level
#[arg(short, long, value_enum, default_value_t = LogLevel::Info)]
log_level: LogLevel,
}
#[derive(Debug, Parser)]
struct RestoreArgs {
/// Archive file to restore
#[arg(short, long)]
archive: PathBuf,
/// Restore destination directory
#[arg(short, long)]
destination: PathBuf,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum LogLevel {
Error,
Warn,
Info,
Debug,
Trace,
}This gives you a strongly typed command model:
PathBuffor filesystem pathsu8for compression levelLogLevelfor a constrained set of values- separate argument structs for each subcommand
The parser will reject invalid enum values automatically. For example, --log-level verbose will fail with a helpful message.
Adding custom validation
Derive-based parsing handles basic type conversion, but real tools often need domain-specific validation. For example:
- compression must be between 0 and 9
- source directory must exist
- output path must not point to a directory
- restore destination must be writable
You can validate after parsing, before executing the command.
use std::fs;
use std::path::Path;
impl BackupArgs {
fn validate(&self) -> Result<(), String> {
if self.compression > 9 {
return Err("compression must be between 0 and 9".into());
}
if !self.source.exists() {
return Err(format!(
"source directory does not exist: {}",
self.source.display()
));
}
if !self.source.is_dir() {
return Err(format!(
"source path is not a directory: {}",
self.source.display()
));
}
if let Some(parent) = self.output.parent() {
if !parent.exists() {
return Err(format!(
"output directory does not exist: {}",
parent.display()
));
}
}
Ok(())
}
}
impl RestoreArgs {
fn validate(&self) -> Result<(), String> {
if !self.archive.exists() {
return Err(format!(
"archive file does not exist: {}",
self.archive.display()
));
}
if !self.archive.is_file() {
return Err(format!(
"archive path is not a file: {}",
self.archive.display()
));
}
if self.destination.exists() && !self.destination.is_dir() {
return Err(format!(
"destination path is not a directory: {}",
self.destination.display()
));
}
Ok(())
}
}A validation method keeps checks close to the data they apply to. That makes the code easier to maintain than scattering if statements throughout your command handlers.
Wiring parsing and execution together
Now add a small dispatcher that parses the CLI and runs the selected command.
fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Backup(args) => run_backup(args),
Commands::Restore(args) => run_restore(args),
};
if let Err(err) = result {
eprintln!("error: {err}");
std::process::exit(1);
}
}
fn run_backup(args: BackupArgs) -> Result<(), String> {
args.validate()?;
println!("Backing up from {}", args.source.display());
println!("Writing archive to {}", args.output.display());
println!("Compression level: {}", args.compression);
println!("Log level: {:?}", args.log_level);
// Real implementation would create the archive here.
Ok(())
}
fn run_restore(args: RestoreArgs) -> Result<(), String> {
args.validate()?;
println!("Restoring archive {}", args.archive.display());
println!("Destination: {}", args.destination.display());
// Real implementation would extract the archive here.
Ok(())
}This pattern is simple but effective:
- parse into typed structures
- validate domain rules
- execute the command
- return a structured error if anything fails
For small tools, this is often enough. For larger tools, you can replace String errors with a custom error enum.
Improving error quality with a custom error type
String-based errors are easy to start with, but a dedicated error type scales better. It lets you distinguish validation failures from I/O failures and makes testing more precise.
use std::fmt;
#[derive(Debug)]
enum CliError {
Validation(String),
Io(std::io::Error),
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CliError::Validation(msg) => write!(f, "{msg}"),
CliError::Io(err) => write!(f, "{err}"),
}
}
}
impl From<std::io::Error> for CliError {
fn from(err: std::io::Error) -> Self {
CliError::Io(err)
}
}Then update your validation methods and handlers to use CliError:
impl BackupArgs {
fn validate(&self) -> Result<(), CliError> {
if self.compression > 9 {
return Err(CliError::Validation(
"compression must be between 0 and 9".into(),
));
}
Ok(())
}
}This is particularly useful when your command performs filesystem operations, reads metadata, or writes output. You can preserve the original error while still presenting a user-friendly message.
Supporting environment-variable overrides
Many CLIs allow environment variables to provide defaults. This is useful in CI pipelines and shell scripts, where repeating flags becomes noisy.
clap supports environment variables directly:
#[derive(Debug, Parser)]
struct BackupArgs {
#[arg(short, long, env = "BACKUP_SOURCE")]
source: PathBuf,
#[arg(short, long, env = "BACKUP_OUTPUT")]
output: PathBuf,
#[arg(short, long, default_value_t = 6)]
compression: u8,
}A practical precedence order is:
- explicit CLI flag
- environment variable
- built-in default
This keeps the CLI predictable while still allowing automation-friendly configuration.
Choosing the right argument type
Not every input should be a plain string. The type you choose communicates intent and reduces validation work.
| Input kind | Recommended type | Why |
|---|---|---|
| File or directory path | PathBuf | Preserves platform-specific path semantics |
| Small bounded integer | u8, u16, etc. | Enforces numeric conversion early |
| Fixed set of options | ValueEnum | Prevents invalid values |
| Optional value | Option<T> | Models presence or absence explicitly |
| Repeated values | Vec<T> | Handles multiple flags naturally |
For example, if your backup tool should accept multiple exclude patterns, you can model them as Vec<String>:
#[derive(Debug, Parser)]
struct BackupArgs {
#[arg(short, long)]
exclude: Vec<String>,
}This is cleaner than manually splitting a comma-separated string, and it works naturally with repeated flags like --exclude target --exclude .git.
Best practices for maintainable CLI design
A typed CLI parser is most effective when the command surface stays intentional. Keep these practices in mind:
Prefer subcommands for distinct workflows
If two operations have different required inputs, separate them into subcommands. This avoids one giant struct full of Option<T> fields.
Validate early, execute late
Do not let invalid input leak into your core logic. Validate paths, ranges, and combinations before any side effects occur.
Keep parsing separate from business logic
The parser should translate input into typed data. The command handler should perform the actual work. This separation improves testability.
Use enums for closed sets
If a value can only be one of a few options, model it as an enum instead of a string. This prevents typo-driven bugs.
Avoid overusing String
Use String only when the value is genuinely free-form. For everything else, prefer a more specific type.
Testing the parser and validation
CLI code is easy to test because parsing is deterministic. You can test both successful parsing and failure cases.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_invalid_compression() {
let args = BackupArgs {
source: PathBuf::from("."),
output: PathBuf::from("archive.zip"),
compression: 10,
log_level: LogLevel::Info,
};
assert!(args.validate().is_err());
}
#[test]
fn accepts_valid_log_level() {
let level = LogLevel::Debug;
assert!(matches!(level, LogLevel::Debug));
}
}For end-to-end CLI tests, you can also test Cli::try_parse_from(...) with argument vectors. That lets you verify flag names, subcommand structure, and error messages without launching a separate process.
When this pattern is a good fit
This approach works well for:
- developer tools
- release automation scripts
- backup and maintenance utilities
- internal admin CLIs
- build helpers and project scaffolding tools
It is especially valuable when correctness matters more than flexibility. If your CLI is expected to run unattended in CI or production-like environments, typed parsing reduces ambiguity and makes failures easier to diagnose.
Summary
A typed CLI parser gives your Rust tools a strong foundation:
claphandles argument parsing and help generation- Rust types encode valid input shapes
- custom validation catches domain-specific mistakes
- subcommands keep workflows clean and discoverable
- environment variables and defaults improve usability
The main idea is simple: parse once, validate once, then work with trusted data. That pattern keeps command-line tools small, predictable, and easy to extend.
