Building Type-Safe CLIs in Python with Maybe Monads
I got tired of writing the same input validation code for every CLI tool. You know the pattern: parse a string, check if it's valid, print an error message, ask again. For every single argument.
Here's what I used to write:
while True:
port_str = input("Enter port: ")
try:
port = int(port_str)
if 1 <= port <= 65535:
break
else:
print("Port must be between 1 and 65535")
except ValueError:
print("Port must be a valid integer")
This works, but it's not composable. You can't reuse the validation logic. You can't combine validators. And error handling is scattered across try/except blocks.
Enter the Maybe Monad
I built valid8r to solve this using Maybe monads. Instead of raising exceptions, parsers return either Success(value) or Failure(error_message).
Here's the same port validation:
from valid8r.core import parsers, validators
result = (
parsers.parse_int(user_input)
.bind(validators.minimum(1))
.bind(validators.maximum(65535))
)
match result:
case Success(port):
print(f"Using port {port}")
case Failure(error):
print(f"Invalid: {error}")
Why This is Better
1. Composable validators
You can combine validators using the & operator:
validator = validators.minimum(1) & validators.maximum(65535)
result = parsers.parse_int(user_input).bind(validator)
2. No exceptions in happy path
Exceptions are for exceptional cases. Invalid user input isn't exceptional, it's expected. Maybe monads make invalid input a normal return value, not an error condition.
3. Reusable parsers
Create a port parser once, use it everywhere:
def parse_port(text):
return parsers.parse_int(text).bind(
validators.minimum(1) & validators.maximum(65535)
)
# Use in argparse
parser.add_argument('--port', type=type_from_parser(parse_port))
# Use in Click
@click.option('--port', callback=callback_from_parser(parse_port))
# Use interactively
from valid8r.prompt import ask
port = ask("Enter port: ", parser=parse_port)
Real-World Example: Building a Server Config CLI
Let's build a CLI that configures a web server. We need to validate:
- Port number (1-65535)
- Email address for admin
- IP address for binding
- SSL certificate path (file must exist)
import argparse
from valid8r.core import parsers, validators
from valid8r.integrations.argparse import type_from_parser
# Define reusable parsers
def parse_port(text):
return parsers.parse_int(text).bind(
validators.minimum(1) & validators.maximum(65535)
)
def parse_cert_path(text):
return parsers.parse_path(text).bind(
validators.path_exists() & validators.is_file()
)
# Build the CLI
parser = argparse.ArgumentParser(description='Configure web server')
parser.add_argument(
'--port',
type=type_from_parser(parse_port),
required=True,
help='Server port (1-65535)'
)
parser.add_argument(
'--admin-email',
type=type_from_parser(parsers.parse_email),
required=True,
help='Administrator email address'
)
parser.add_argument(
'--bind-ip',
type=type_from_parser(parsers.parse_ipv4),
default='127.0.0.1',
help='IP address to bind to'
)
parser.add_argument(
'--ssl-cert',
type=type_from_parser(parse_cert_path),
help='Path to SSL certificate'
)
args = parser.parse_args()
Now when users provide invalid input, they get helpful error messages:
$ python server.py --port 70000 --admin-email bob --bind-ip 999.999.999.999
error: argument --port: Value must be at most 65535
Interactive Prompts with Automatic Retry
For interactive CLIs, valid8r keeps asking until it gets valid input:
from valid8r.prompt import ask
port = ask(
"Enter server port (1-65535): ",
parser=parse_port
)
email = ask(
"Enter admin email: ",
parser=parsers.parse_email
)
print(f"Server will run on port {port}")
print(f"Admin notifications sent to {email}")
The user sees:
Enter server port (1-65535): 70000
Invalid input: Value must be at most 65535
Enter server port (1-65535): 8080
Enter admin email: not-an-email
Invalid input: The email address is not valid
Enter admin email: admin@example.com
Server will run on port 8080
Admin notifications sent to admin@example.com
No manual retry loops. No try/except blocks. Just declarative validation.
Performance Note
If you're familiar with Pydantic, you might wonder how this compares. For simple parsing (ints, emails, UUIDs), valid8r is 4-300x faster because it doesn't build schemas or do runtime type checking. It just parses strings and returns Maybe[T].
For complex nested object validation in FastAPI applications, use Pydantic. For CLI tools and network config parsing, Maybe monads compose really nicely.
I benchmarked both and documented the results.
Built-in Parsers
Valid8r includes parsers for common types:
Basic types: parse_int, parse_float, parse_bool, parse_date, parse_decimal
Network: parse_ipv4, parse_ipv6, parse_url, parse_email, parse_phone
Advanced: parse_uuid, parse_enum, parse_path
Collections: parse_list, parse_dict, parse_set (with element parsers)
CLI Framework Integration
Works with argparse (stdlib), Click, and Typer:
# argparse
from valid8r.integrations.argparse import type_from_parser
parser.add_argument('--email', type=type_from_parser(parsers.parse_email))
# Click
from valid8r.integrations.click import callback_from_parser
@click.option('--email', callback=callback_from_parser(parsers.parse_email))
# Typer
from valid8r.integrations.typer import TyperParser
app = typer.Typer()
Email = TyperParser(parsers.parse_email)
@app.command()
def send(email: Email):
print(f"Sending to {email}")
Try It Out
pip install valid8r
Full docs at valid8r.readthedocs.io
Source on GitHub (MIT licensed)
Is This Too Weird for Python?
Maybe monads aren't common in Python, but they solve a real problem: making validation composable and testable without exceptions. If you've ever written the same input validation loop 50 times, give it a try.
I'd love feedback on the API design. Does this make validation code cleaner, or is the functional style too foreign for Python?
Top comments (0)