DEV Community

Mike Lane
Mike Lane

Posted on

CLI Validation Patterns with Maybe Monads

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

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

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") returns Success(8080)
  • .bind(validators.minimum(1)) receives 8080, checks 8080 >= 1, returns Success(8080)
  • .bind(validators.maximum(65535)) receives 8080, checks 8080 <= 65535, returns Success(8080)

If the input is "70000":

  • parsers.parse_int("70000") returns Success(70000)
  • .bind(validators.minimum(1)) returns Success(70000)
  • .bind(validators.maximum(65535)) returns Failure("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)
    )
Enter fullscreen mode Exit fullscreen mode

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

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

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

The ask function handles the prompt loop:

  1. Display prompt with default value if provided
  2. Parse input using the parser function
  3. Validate using the validator function (if provided)
  4. On failure, display error and retry (if retry is True or a positive integer)
  5. 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
)
Enter fullscreen mode Exit fullscreen mode

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

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.

Top comments (1)

Collapse
 
onlineproxyio profile image
OnlineProxy

Maybe/Result makes CLI validation way clearer-explicit control flow, reusable validators, easier tests, and centralized user-facing errors without the IO/exit-code drama. Put valid8r at the CLI edge and hydrate pydantic/attrs models after, wire into argparse via type_from_parser or into click/Typer with a ParamType/callback raising BadParameter, and keep errors short with option name, expected vs got, plus a quick hint. Testing-wise, do tiny unit tests per validator, sprinkle Hypothesis for edge cases, snapshot user messages, pass context via currying/Reader, treat monadic overhead as noise next to IO, default to Result for user-visible errors, and migrate gradually by wrapping old exception-y code with Result adapters.