CLI Validation Patterns with Maybe Monads
CLI input validation typically scatters error handling across multiple try/except blocks, making the control flow difficult to follow and test. The Maybe monad offers an alternative: compose validation steps into pipelines where errors propagate automatically, and each step either succeeds with a value or fails with an error message.
This article demonstrates four patterns for CLI validation using valid8r, a Python library that implements the Maybe monad for parsing and validation.
The Problem with Nested Exception Handling
Consider validating a configuration file path. The input must pass several checks: the path must exist, be a file (not a directory), be readable, have a .json extension, and contain valid JSON with required keys.
import os
import json
def validate_config_file(path: str) -> dict:
if not os.path.exists(path):
raise ValueError(f"{path} does not exist")
if not os.path.isfile(path):
raise ValueError(f"{path} is not a file")
if not os.access(path, os.R_OK):
raise ValueError(f"{path} is not readable")
if not path.endswith('.json'):
raise ValueError(f"{path} must be .json")
try:
with open(path) as f:
config = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}")
required = ['database', 'api_key']
missing = [k for k in required if k not in config]
if missing:
raise ValueError(f"Missing keys: {missing}")
return config
This function works, but has structural problems. Each check is an independent if-statement, so adding or removing checks requires modifying multiple locations. The JSON parsing sits inside a nested try-except. Testing requires mocking filesystem state or creating actual files.
The Maybe monad addresses these issues by representing each validation step as a function that returns either Success(value) or Failure(error). Steps compose through bind, which passes the value to the next function if the previous succeeded, or short-circuits if it failed.
Pattern 1: Chained Validation with bind
The bind method chains validation steps. If any step fails, the remaining steps are skipped and the error propagates to the end.
from valid8r import parsers, validators
from valid8r.core.maybe import Maybe, Success, Failure
def validate_port(text: str) -> Maybe[int]:
"""Parse and validate a port number in range 1-65535."""
return (
parsers.parse_int(text)
.bind(validators.minimum(1))
.bind(validators.maximum(65535))
)
# Usage
result = validate_port("8080")
match result:
case Success(port):
print(f"Starting server on port {port}")
case Failure(error):
print(f"Invalid port: {error}")
Each validator is a function that takes a value and returns Maybe[T]. The bind method unwraps Success, passes the value to the next function, and returns the result. If the current result is Failure, bind returns it unchanged.
For port validation:
-
parsers.parse_int("8080")returnsSuccess(8080) -
.bind(validators.minimum(1))receives8080, checks8080 >= 1, returnsSuccess(8080) -
.bind(validators.maximum(65535))receives8080, checks8080 <= 65535, returnsSuccess(8080)
If the input is "70000":
-
parsers.parse_int("70000")returnsSuccess(70000) -
.bind(validators.minimum(1))returnsSuccess(70000) -
.bind(validators.maximum(65535))returnsFailure("Value must be at most 65535")
The chain stops at the first failure. No subsequent validators execute.
Pattern 2: Combining Validators with Operators
The Validator class supports & (and), | (or), and ~ (not) operators for combining validators.
from valid8r import parsers, validators
from valid8r.core.maybe import Maybe
def validate_username(text: str) -> Maybe[str]:
"""Username: 3-20 chars, alphanumeric with underscores."""
return parsers.parse_str(text).bind(
validators.length(3, 20)
& validators.matches_regex(r'^[a-zA-Z0-9_]+$')
)
def validate_age(text: str) -> Maybe[int]:
"""Age: positive integer, max 150."""
return parsers.parse_int(text).bind(
validators.minimum(0) & validators.maximum(150)
)
The & operator creates a validator that passes only if both validators pass. The | operator passes if either validator passes. The ~ operator inverts a validator.
from pathlib import Path
from valid8r import parsers, validators
def validate_output_path(text: str) -> Maybe[Path]:
"""Output path: must be a directory OR a writable file."""
return parsers.parse_path(text, resolve=True).bind(
validators.exists()
).bind(
validators.is_dir() | (validators.is_file() & validators.is_writable())
)
def validate_safe_upload(text: str) -> Maybe[Path]:
"""Upload: must exist, be readable, NOT be executable."""
return parsers.parse_path(text).bind(
validators.exists()
& validators.is_readable()
& ~validators.is_executable()
)
The ~validators.is_executable() validator passes if the file is not executable. This inverts the success/failure of the wrapped validator.
Pattern 3: Filesystem Validation Pipelines
Filesystem validation often requires multiple checks in sequence: existence, type, permissions, and constraints. The Maybe pattern handles this with composable pipelines.
from pathlib import Path
from valid8r import parsers, validators
from valid8r.core.maybe import Maybe, Success, Failure
def validate_config_file(path_str: str) -> Maybe[Path]:
"""Validate configuration file: exists, readable, YAML/JSON, under 1MB."""
return (
parsers.parse_path(path_str, expand_user=True, resolve=True)
.bind(validators.exists())
.bind(validators.is_file())
.bind(validators.is_readable())
.bind(validators.has_extension(['.yaml', '.yml', '.json']))
.bind(validators.max_size(1024 * 1024))
)
def validate_upload_file(path_str: str) -> Maybe[Path]:
"""Validate uploaded file: PDF/DOCX, readable, under 10MB."""
return (
parsers.parse_path(path_str)
.bind(validators.exists())
.bind(validators.is_file())
.bind(validators.is_readable())
.bind(validators.has_extension(['.pdf', '.docx']))
.bind(validators.max_size(10 * 1024 * 1024))
)
# Usage in a CLI handler
def handle_upload(file_path: str) -> dict:
match validate_upload_file(file_path):
case Success(path):
return {
'status': 'success',
'filename': path.name,
'size': path.stat().st_size
}
case Failure(error):
return {
'status': 'error',
'message': error
}
The parse_path function supports options like expand_user=True (expands ~ to home directory) and resolve=True (converts to absolute path). These run before validation begins.
Adding or removing validation steps requires changing one line. Each step is independently testable. Error messages propagate automatically with context about which check failed.
Pattern 4: Interactive Prompts with Retry Logic
The ask function combines parsing, validation, and retry logic for interactive CLI prompts.
from valid8r import parsers, validators
from valid8r.prompt import ask
def get_user_config() -> dict:
"""Prompt user for configuration with validation and retry."""
# Port with range validation, retry up to 3 times
port_result = ask(
"Enter port (1-65535): ",
parser=parsers.parse_int,
validator=validators.between(1, 65535),
default=8080,
retry=3
)
# Email with RFC validation, unlimited retries
email_result = ask(
"Enter email: ",
parser=parsers.parse_email,
retry=True
)
# Boolean with various formats accepted (yes/no, true/false, y/n, 1/0)
debug_result = ask(
"Enable debug mode? ",
parser=parsers.parse_bool,
default=False
)
return {
'port': port_result.value_or(8080),
'email': email_result.value_or(None),
'debug': debug_result.value_or(False)
}
The ask function handles the prompt loop:
- Display prompt with default value if provided
- Parse input using the parser function
- Validate using the validator function (if provided)
- On failure, display error and retry (if
retryisTrueor a positive integer) - Return
Maybe[T]with final result
For custom validation, compose a parser with validators using bind:
def custom_port_parser(text: str) -> Maybe[int]:
return parsers.parse_int(text).bind(validators.between(1, 65535))
port_result = ask(
"Enter port: ",
parser=custom_port_parser,
retry=True
)
Integrating with argparse
The type_from_parser adapter connects valid8r parsers to argparse:
import argparse
from valid8r import parsers, validators
from valid8r.integrations.argparse import type_from_parser
from valid8r.core.maybe import Maybe
def port_parser(text: str) -> Maybe[int]:
return parsers.parse_int(text).bind(
validators.minimum(1) & validators.maximum(65535)
)
parser = argparse.ArgumentParser()
parser.add_argument(
'--email',
type=type_from_parser(parsers.parse_email),
required=True,
help='Email address'
)
parser.add_argument(
'--port',
type=type_from_parser(port_parser),
default=8080,
help='Port number (1-65535)'
)
args = parser.parse_args()
# args.email is EmailAddress(local='user', domain='example.com')
# args.port is int
When validation fails, argparse displays the error message from the Failure and exits with status 2. The error message comes from the validator, not a generic type conversion error.
Tradeoffs
The Maybe monad pattern has costs:
Cognitive overhead: Developers unfamiliar with monads need to learn bind, map, Success, and Failure. The functional style differs from imperative Python.
Stack traces: When validation fails deep in a pipeline, the stack trace points to bind internals rather than the specific validator. Error messages must be descriptive since line numbers are less helpful.
Type inference: Complex chains can confuse type checkers. Explicit type annotations help.
Overkill for simple cases: A single if not value: raise ValueError() is clearer than a Maybe pipeline for trivial validation.
The pattern pays off when:
- Validation requires multiple sequential steps
- Error messages need to propagate unchanged
- Validation logic should be testable in isolation
- The same validators compose into different pipelines
Summary
The Maybe monad transforms validation from scattered conditionals into composable pipelines. Each validator is a function from T to Maybe[T], and bind chains them together. Operators (&, |, ~) combine validators logically. The ask function adds interactive prompting with retry logic.
Install with pip install valid8r. The source is at github.com/mikelane/valid8r.
Code examples tested with valid8r 1.25.0 on Python 3.12.
