Build Modern CLI Tools with Typer and Python: Type Hinting, Validation, and Professional Command Line Interfaces

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Context & Why: The 2 AM Script Problem

It was 2:07 AM when the on-call alert fired. A batch job had silently failed because someone ran the maintenance script with --mode=delet instead of --mode=delete. No validation, no error. Just… nothing happened. Three hours of debugging later, we found the root cause: a hand-rolled argparse script that accepted any string without checking it.

That night convinced me to stop writing ad-hoc CLI scripts and start building proper tools. Since then, I’ve rebuilt four internal tools with Typer across two different teams. Bad-argument failures dropped to zero. The --help output stopped lying.

Typer is a Python library built on top of Click. It uses Python type hints to define CLI arguments, options, and validation automatically. Write a normal Python function with type annotations, and Typer generates the interface — including --help text, completion scripts, and input validation.

Here’s what makes it different from argparse or plain Click:

  • Type hints are the argument definitions — no duplication
  • Validation happens before your function runs — bad input never reaches your logic
  • Autocomplete works out of the box for bash, zsh, and fish
  • Subcommands are just Python functions grouped under an app

Installation

Start with a clean virtual environment — mixing Typer versions across projects causes subtle issues with Click compatibility:

python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install "typer[all]"

The [all] extra pulls in rich for colored output and shellingham for shell detection (needed for autocomplete installation). If you’re building something minimal for a Docker container, pip install typer works too.

Verify the install:

python -c "import typer; print(typer.__version__)"

Configuration: Building Your First Real CLI

Forget the toy “hello world” examples. Here’s the kind of thing that actually runs in production — a database maintenance tool with subcommands, enums for validated options, and proper error handling.

Basic App Structure

import typer
from enum import Enum
from pathlib import Path
from typing import Optional

app = typer.Typer(name="dbmaint", help="Database maintenance toolkit")

class RunMode(str, Enum):
    dry_run = "dry-run"
    execute = "execute"
    rollback = "rollback"

@app.command()
def cleanup(
    database: str = typer.Argument(..., help="Database name to target"),
    mode: RunMode = typer.Option(RunMode.dry_run, help="Execution mode"),
    older_than_days: int = typer.Option(30, min=1, max=365, help="Delete records older than N days"),
    config: Optional[Path] = typer.Option(None, exists=True, help="Path to config file"),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
):
    """Clean up old records from the target database."""
    if verbose:
        typer.echo(f"Mode: {mode.value}, DB: {database}, Days: {older_than_days}")
    
    if mode == RunMode.dry_run:
        typer.secho("[DRY RUN] No changes will be made", fg=typer.colors.YELLOW)
    elif mode == RunMode.execute:
        typer.secho(f"Cleaning records older than {older_than_days} days...", fg=typer.colors.GREEN)
        # actual logic here
    
if __name__ == "__main__":
    app()

All three validations are automatic. RunMode is an Enum, so passing --mode=delet fails immediately with a clear error listing valid choices. The min=1, max=365 constraint on older_than_days rejects out-of-range integers before your function runs. The exists=True on the config path checks that the file actually exists on disk.

Running it without arguments shows clean help output:

python dbmaint.py cleanup --help
Usage: dbmaint cleanup [OPTIONS] DATABASE

  Clean up old records from the target database.

Arguments:
  DATABASE  Database name to target  [required]

Options:
  --mode [dry-run|execute|rollback]  Execution mode  [default: dry-run]
  --older-than-days INTEGER RANGE    Delete records older than N days  [default: 30]
  --config PATH                      Path to config file
  -v, --verbose / --no-verbose
  --help                             Show this message and exit.

Adding Subcommands

Real tools have multiple operations. With Typer, each subcommand is a separate function decorated with @app.command():

@app.command()
def backup(
    database: str = typer.Argument(...),
    output: Path = typer.Option(Path("./backups"), writable=True, help="Backup destination"),
    compress: bool = typer.Option(True, help="Compress output with gzip"),
):
    """Create a database backup."""
    typer.echo(f"Backing up {database} to {output}")
    # backup logic here

@app.command()
def restore(
    database: str = typer.Argument(...),
    backup_file: Path = typer.Argument(..., exists=True, help="Backup file to restore from"),
    force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
):
    """Restore a database from backup."""
    if not force:
        confirm = typer.confirm(f"This will overwrite {database}. Continue?")
        if not confirm:
            raise typer.Abort()
    typer.echo(f"Restoring {database} from {backup_file}")

Run python dbmaint.py --help and all three commands appear automatically. Nobody maintains a dispatch table. Nobody updates help text by hand — which saves a surprising amount of time in code review.

Nested Apps for Complex Tools

For tools with many commands, group them into sub-apps:

import typer

app = typer.Typer()
db_app = typer.Typer(help="Database operations")
user_app = typer.Typer(help="User management")

app.add_typer(db_app, name="db")
app.add_typer(user_app, name="user")

@db_app.command("migrate")
def db_migrate(target: str = typer.Argument("head")):
    """Run database migrations."""
    typer.echo(f"Migrating to: {target}")

@user_app.command("create")
def user_create(username: str, email: str, admin: bool = False):
    """Create a new user account."""
    typer.echo(f"Creating user: {username} ({email})")

Usage becomes python tool.py db migrate head and python tool.py user create johndoe [email protected]. The hierarchy shows up in --help at every level.

Custom Validation with Callbacks

Sometimes the built-in constraints aren’t enough. Typer supports parameter callbacks for custom validation:

import re

def validate_db_name(value: str) -> str:
    if not re.match(r'^[a-z][a-z0-9_]{2,63}$', value):
        raise typer.BadParameter(
            "Database name must start with a letter, contain only lowercase letters, "
            "digits, or underscores, and be 3-64 characters long."
        )
    return value

@app.command()
def create_db(
    name: str = typer.Argument(..., callback=validate_db_name, help="New database name"),
):
    """Create a new database."""
    typer.secho(f"Creating database: {name}", fg=typer.colors.GREEN)

The callback runs before your function body. Pass a bad name and you get a clear error with the parameter name, the value, and your message — before any database connection is attempted.

Verification & Monitoring

Testing CLI Commands

Typer ships with a test client that captures output without spawning a subprocess — critical for fast unit tests:

from typer.testing import CliRunner
from myapp import app

runner = CliRunner()

def test_cleanup_dry_run():
    result = runner.invoke(app, ["cleanup", "mydb", "--mode", "dry-run"])
    assert result.exit_code == 0
    assert "DRY RUN" in result.output

def test_cleanup_invalid_mode():
    result = runner.invoke(app, ["cleanup", "mydb", "--mode", "invalid"])
    assert result.exit_code != 0
    assert "Invalid value" in result.output

def test_cleanup_days_out_of_range():
    result = runner.invoke(app, ["cleanup", "mydb", "--older-than-days", "999"])
    assert result.exit_code != 0

These tests run in ~5ms each. Watch the exit_code — Typer exits with code 2 for bad arguments, code 1 for Abort(), and code 0 for success. In a CI pipeline, those codes are what your test suite catches before anything ships.

Exit Codes and Shell Integration

When wrapping Typer tools in shell scripts or cron jobs, exit codes matter:

# In your cron job or CI script:
python dbmaint.py cleanup production --mode execute
if [ $? -ne 0 ]; then
    echo "Cleanup failed" | mail -s "DB Alert" [email protected]
fi

Typer propagates exit codes correctly from raised exceptions. Use raise typer.Exit(code=1) to signal failure from within your logic. Shell wrappers pick it up without any extra configuration.

Shell Autocomplete

Once a tool is deployed, install autocompletion so the team actually uses it correctly:

# Install completion for the current shell:
python dbmaint.py --install-completion

# Or generate the script to inspect it:
python dbmaint.py --show-completion

After sourcing your shell config, tab-completing python dbmaint.py cleanup --mode <TAB> shows dry-run execute rollback. That’s the kind of discoverability that keeps people from running the wrong mode at 2 AM.

Packaging as a Proper CLI Tool

Wire Typer into a pyproject.toml entry point to make it installable system-wide:

[project]
name = "dbmaint"
version = "1.0.0"
dependencies = ["typer[all]>=0.12"]

[project.scripts]
dbmaint = "dbmaint.cli:app"
pip install -e .
# Now usable as:
dbmaint cleanup production --mode dry-run

This makes the tool available as a system command, with the same autocomplete and validation as running the script directly.

Moving from raw argparse to Typer changed how our team operates. Bad arguments fail loudly at the CLI layer — not silently inside business logic. The --help text stays in sync with the code automatically. New team members discover options through autocomplete instead of reading source. That 2 AM incident was the last time a typo in a CLI argument caused a production problem.

Share: