CLI Validation Patterns with Maybe Monads
Source: Dev.to
Example: Validating a Configuration File Path
The input must pass several checks:
- the path exists
- it is a file (not a directory)
- it is readable
- it has a
.jsonextension - it contains 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
The function works, but it has structural problems:
- Each check is an independent
if‑statement, so adding or removing checks requires touching multiple locations. - The JSON parsing sits inside a nested
try/except. - Testing requires mocking filesystem state or creating actual files.
The Maybe Monad Solution
The Maybe monad represents 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.
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.
How the chain works
| Input | Step | Result |
|---|---|---|
"8080" | parsers.parse_int | Success(8080) |
.bind(validators.minimum(1)) | Success(8080) | |
.bind(validators.maximum(65535)) | Success(8080) | |
"70000" | parsers.parse_int | Success(70000) |
.bind(validators.minimum(1)) | Success(70000) | |
.bind(validators.maximum(65535)) | Failure("Value must be at most 65535") |
The chain stops at the first failure; subsequent validators are never executed.
Combining Validators with Logical Operators
The Validator class supports & (and), | (or), and ~ (not) operators for building more complex rules.
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)
)
&creates a validator that passes only if both validators pass.|passes if either validator passes.~inverts a validator (i.e., succeeds when the wrapped validator fails).
from pathlib import Path
from valid8r import parsers, validators
from valid8r.core.maybe import Maybe
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 succeeds when the file is not executable, effectively inverting the original check.
File‑System Validation Pipelines
Filesystem validation often requires multiple sequential checks: existence, type, permissions, and content 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 1 MB."""
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 10 MB."""
return (
parsers.parse_path(path_str)
.bind(validators.exists())
.bind(validators.is_file())
.bind(validators.has_extension(['.pdf', '.docx']))
.bind(validators.is_readable())
.bind(validators.max_size(10 * 1024 * 1024))
)
Each pipeline reads naturally from left to right, and any failure short‑circuits the rest of the chain, returning a Failure with a helpful error message.
Validation Pipeline
d(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 the home directory) and resolve=True (converts to an 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.
Interactive Prompting with ask
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)
}
How ask works
- Display prompt – shows the default value if one is provided.
- Parse input – uses the supplied parser function.
- Validate – runs the validator (if any).
- Retry on failure – shows the error and repeats the prompt when
retryisTrueor a positive integer. - Return – a
Maybe[T]containing the final result.
Custom Validation Pipelines
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
)
Argparse Integration
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 → EmailAddress(local='user', domain='example.com')
# args.port → int
When validation fails, argparse displays the error message from the Failure and exits with status 2. The message originates from the validator, not from a generic type‑conversion error.
Trade‑offs of the Maybe‑Monad Pattern
Costs
| Issue | Explanation |
|---|---|
| Cognitive overhead | Developers unfamiliar with monads must learn bind, map, Success, and Failure. The functional style differs from typical imperative Python. |
| Stack traces | Failures deep in a pipeline point to bind internals rather than the specific validator, making debugging harder. Descriptive error messages are essential. |
| Type inference | Complex chains can confuse static type checkers; explicit type annotations help mitigate this. |
| Overkill for simple cases | A straightforward if not value: raise ValueError() is clearer for trivial validation. |
When It Pays Off
- Multiple sequential validation steps are required.
- Error messages need to propagate unchanged through the pipeline.
- Validation logic should be testable in isolation.
- The same validators are reused across different pipelines.
The Maybe monad transforms validation from scattered conditionals into composable pipelines. Each validator is a function T → Maybe[T], and bind chains them together. Logical operators (&, |, ~) combine validators, while ask adds interactive prompting with retry logic.
Installation & Resources
pip install valid8r
- Source code:
- Tested version:
valid8r 1.25.0on Python 3.12.