Building Modern CLIs with Typer Python: Type Hints, Auto-completion, and Automatic Validation

Python tutorial - IT technology blog
Python tutorial - IT technology blog

Why argparse becomes a burden as your project grows

My automation project started at just 200 lines — mostly a few basic commands using argparse. But a few months later, when the codebase grew to nearly 2,000 lines, CLI management alone took up around 300 lines — endless add_argument, type=int, help=... repeated over and over. Every time I needed to add a new command, I’d have to scroll down and find the right spot, terrified of breaking something that already worked.

argparse isn’t wrong — it just predates Python’s type hints. click is better, but you still have to declare separate decorators for each parameter. Then Typer came along and completely changed the way I write CLIs.

What Typer is and how it differs from argparse/click

Typer is a CLI library built on top of Click, but instead of using decorators to declare parameters, it reads directly from Python function type hints. This means:

  • You write normal Python functions with type hints
  • Typer automatically creates the CLI interface, validates data, and generates help text
  • Auto-completion works out-of-the-box with a single command
  • Less code, more readable, and type-safe from the start

In short: type hints serve as both code documentation and a “schema” that Typer uses to build the CLI — no need to declare things twice in two different places.

Installation and your first command

Install Typer with full features including rich output and auto-completion:

pip install "typer[all]"

Create a main.py file with your first command:

import typer

app = typer.Typer()

@app.command()
def greet(name: str, count: int = 1):
    """Greet the user."""
    for _ in range(count):
        typer.echo(f"Hello, {name}!")

if __name__ == "__main__":
    app()

Try it right away:

python main.py --help
python main.py World
python main.py "John Doe" --count 3

The --help output is automatically generated from type hints — name is a required argument because it has no default value, and count is optional with a default of 1. No extra code needed.

Automatic Validation with Type Hints

This is the biggest strength. When you declare count: int, Typer automatically validates it and returns a clear error if the user enters an invalid value:

python main.py World --count abc
# Error: Invalid value for '--count': 'abc' is not a valid integer.

You can also use more complex data types like Path:

from pathlib import Path
from typing import Optional
import typer

app = typer.Typer()

@app.command()
def process_file(
    input_file: Path = typer.Argument(..., help="Input file to process"),
    output_dir: Path = typer.Option(Path("."), help="Directory to save results"),
    verbose: bool = False,
    max_lines: Optional[int] = None,
):
    if not input_file.exists():
        typer.echo(f"Error: File {input_file} does not exist", err=True)
        raise typer.Exit(1)

    typer.echo(f"Processing: {input_file} → {output_dir}")
    if verbose:
        typer.echo(f"Limit: {max_lines or 'unlimited'} lines")

Path type automatically validates that the path is valid. Optional[int] accepts an int or None. The ... in typer.Argument(...) means required — no default value.

Enum — Restricting to a Set of Valid Values

Instead of manually validating with if format not in ["json", "csv"], use an Enum and let Typer handle it:

from enum import Enum
import typer

app = typer.Typer()

class OutputFormat(str, Enum):
    json = "json"
    csv = "csv"
    text = "text"

@app.command()
def export(
    data: str,
    format: OutputFormat = OutputFormat.json,
):
    typer.echo(f"Exporting data in format: {format.value}")
python main.py export "data" --format csv
python main.py export "data" --format xml
# Error: Invalid value for '--format': 'xml' is not one of 'json', 'csv', 'text'.

Bonus: auto-completion will also suggest exactly these 3 values when the user presses Tab in the terminal.

Subcommands — Organizing Commands into Groups

As your project grows, a single command isn’t enough. Typer supports nested subcommands cleanly using add_typer():

import typer
from pathlib import Path

app = typer.Typer()
db_app = typer.Typer()
user_app = typer.Typer()

app.add_typer(db_app, name="db", help="Manage database")
app.add_typer(user_app, name="user", help="Manage users")

@db_app.command("migrate")
def db_migrate(version: str = "latest"):
    typer.echo(f"Running migration to version: {version}")

@db_app.command("backup")
def db_backup(output: Path = Path("backup.sql")):
    typer.echo(f"Backup database → {output}")

@user_app.command("create")
def user_create(username: str, email: str, admin: bool = False):
    role = "admin" if admin else "user"
    typer.echo(f"Creating {role}: {username} ({email})")

if __name__ == "__main__":
    app()

The command structure becomes clear and easy to extend:

python main.py db migrate --version 1.5
python main.py db backup --output /tmp/backup.sql
python main.py user create john [email protected] --admin
python main.py --help  # show all command groups

Enabling Auto-completion with a Single Command

This is my favorite feature — something argparse doesn’t have, and click requires complex configuration for:

# Bash
python main.py --install-completion bash

# Zsh
python main.py --install-completion zsh

# Fish
python main.py --install-completion fish

# Reload shell to activate
source ~/.bashrc

Then press Tab while typing a command — Typer suggests subcommands, options, and Enum values automatically. For tools with many commands, this feature saves users a significant amount of time.

Rich Output — Tables and Colors in the Terminal

Typer bundles rich, so you can print tables, progress bars, and colors without any extra installation:

from rich.console import Console
from rich.table import Table
import typer

console = Console()
app = typer.Typer()

@app.command()
def list_tasks():
    """Display the list of tasks."""
    table = Table(title="Task List")
    table.add_column("ID", style="cyan", width=6)
    table.add_column("Name", style="white")
    table.add_column("Status", style="green")

    table.add_row("1", "Backup database", "✓ Done")
    table.add_row("2", "Deploy staging", "⟳ Running")
    table.add_row("3", "Send report", "✗ Error")

    console.print(table)

Organizing Code as Your Project Grows

A hard lesson from my 2,000-line project: don’t cram all your commands into one file. The pattern I use is to split each command group into its own module:

mycli/
├── __init__.py      # main app + add_typer()
├── commands/
│   ├── __init__.py
│   ├── db.py        # db_app = typer.Typer()
│   ├── user.py      # user_app = typer.Typer()
│   └── report.py    # report_app = typer.Typer()
main.py              # entry point

The mycli/__init__.py file:

import typer
from mycli.commands.db import db_app
from mycli.commands.user import user_app
from mycli.commands.report import report_app

app = typer.Typer(name="mycli", help="Project management tool")
app.add_typer(db_app, name="db")
app.add_typer(user_app, name="user")
app.add_typer(report_app, name="report")

Each file exports an independent typer.Typer() instance. Adding a new command group only requires creating a new file and adding 2 lines to __init__.py — no touching existing code.

Compared to using argparse, my CLI section shrank from ~300 to ~150 lines after switching to Typer, while also eliminating dozens of small validation bugs that I’d previously forgotten to handle.

Conclusion

Typer isn’t magic — it simply leverages what you’re already used to writing (functions + type hints) to generate a complete CLI with validation, help text, auto-completion, and rich output. If you’re using argparse and finding your CLI code getting messy, now is a good time to give it a try.

A practical approach: convert one small command first, get it running and get comfortable with the pattern, then gradually migrate the rest. Don’t try to refactor everything all at once.

Share: