DEV Community

Cover image for Building Type-Safe CLIs in Python with Maybe Monads
Mike Lane
Mike Lane

Posted on

Building Type-Safe CLIs in Python with Maybe Monads

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")
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

Try It Out

pip install valid8r
Enter fullscreen mode Exit fullscreen mode

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)