cheat sheet

typer

Build command-line interfaces using Python type annotations with Typer. Covers commands, options, arguments, subcommands, callbacks, rich output, and testing.

typer — CLI Apps with Type Hints

What it is

Typer is a Python library for building CLI applications using standard type annotations. You write plain Python functions with typed parameters; Typer converts them into commands, options, and arguments automatically, including --help text derived from docstrings and type signatures. Typer is built on Click and integrates with Rich for colour output. It is the CLI equivalent of FastAPI — minimal boilerplate, maximal type safety.

Install

bash
pip install typer
pip install "typer[all]"   # includes rich (colour output) and shellingham (shell detection)

Output: (none — exits 0 on success)

Quick example

python
import typer

app = typer.Typer()

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

if __name__ == "__main__":
    app()

Usage:

bash
python main.py Alice --times 3

Output:

text
Hello, Alice!
Hello, Alice!
Hello, Alice!
bash
python main.py --help

Output:

text
Usage: main.py [OPTIONS] NAME

  Greet a user a given number of times.

Arguments:
  NAME  [required]

Options:
  --times INTEGER  [default: 1]
  --help           Show this message and exit.

When / why to use it

  • Turning Python scripts into proper CLIs without writing argparse boilerplate.
  • Multi-command CLIs (like git, docker) with subcommands and shared options.
  • When type safety matters — all inputs are validated and converted by Python's type system.
  • Applications that already use Pydantic or FastAPI — the same type-annotation idioms carry over.
  • When you want rich terminal output (colour, progress bars, tables) without extra setup.

Common pitfalls

Optional[str] vs str = None — in Typer, Optional[str] = None makes the option optional with no default prompt. Plain str (no default) makes it a required positional argument. The distinction matters: def cmd(name: Optional[str] = None) creates --name option; def cmd(name: str) creates a required NAME argument.

Single-command apps don't need Typer() — if you only have one command, use typer.run(fn) instead of @app.command(). Mixing both causes the decorator to be ignored.

Annotate with typer.Option(...) or typer.Argument(...) for explicit control over prompts, environment-variable fallbacks, and help text. Bare type annotations use sensible defaults but can't specify those extras.

typer.echo() is equivalent to print() but integrates with Typer's test runner. For rich formatting, use from rich import print directly — Typer's [all] extra includes Rich.

Arguments vs Options

An argument is a positional parameter (no -- flag). An option is a named flag (--flag value). In Typer, the distinction is controlled by the default: parameters without a default become arguments; parameters with a default become options.

python
import typer

app = typer.Typer()

@app.command()
def process(
    filename: str,                              # argument — positional, required
    output: str = "result.txt",                 # option  — --output
    verbose: bool = False,                      # option  — --verbose / --no-verbose
    count: int = typer.Option(1, min=1, max=100),  # option with constraints
):
    """Process a file and write results."""
    typer.echo(f"Processing {filename}{output}{count})")
    if verbose:
        typer.echo("Verbose mode on")

if __name__ == "__main__":
    app()

Usage:

bash
python main.py data.csv --output out.csv --verbose --count 5

Output:

text
Processing data.csv → out.csv (×5)
Verbose mode on

Explicit Argument and Option annotations

typer.Argument and typer.Option give you full control over help text, env vars, prompts, and constraints.

python
import typer
from typing import Optional

app = typer.Typer()

@app.command()
def deploy(
    environment: str = typer.Argument(
        ..., help="Target environment: dev, staging, prod"
    ),
    branch: str = typer.Option(
        "main", "--branch", "-b",
        help="Git branch to deploy",
        envvar="DEPLOY_BRANCH",
    ),
    dry_run: bool = typer.Option(
        False, "--dry-run", help="Simulate deployment without executing"
    ),
    token: Optional[str] = typer.Option(
        None, envvar="DEPLOY_TOKEN", help="Auth token (or set DEPLOY_TOKEN env var)"
    ),
):
    """Deploy a branch to the specified environment."""
    typer.echo(f"Deploying {branch}{environment}" + (" [DRY RUN]" if dry_run else ""))

if __name__ == "__main__":
    app()

Usage:

bash
python deploy.py prod --branch feature/auth --dry-run

Output:

text
Deploying feature/auth → prod [DRY RUN]

Subcommands — multi-command apps

Create multiple @app.command() functions on the same Typer() instance to get a multi-command CLI. Each function becomes a subcommand named after the function (with underscores replaced by hyphens).

python
import typer

app = typer.Typer(help="Project management CLI")

@app.command()
def init(name: str, template: str = "default"):
    """Initialise a new project."""
    typer.echo(f"Initialising '{name}' with template '{template}'")

@app.command()
def build(target: str = "release", jobs: int = 4):
    """Build the project."""
    typer.echo(f"Building ({target}, {jobs} jobs)")

@app.command()
def clean():
    """Remove build artefacts."""
    typer.echo("Cleaning build directory")

if __name__ == "__main__":
    app()

Usage:

bash
python main.py --help
python main.py init my-project
python main.py build --target debug --jobs 8

Output:

text
Usage: main.py [OPTIONS] COMMAND [ARGS]...

  Project management CLI

Commands:
  build  Build the project.
  clean  Remove build artefacts.
  init   Initialise a new project.

Nested subcommands — sub-apps

Compose Typer apps by adding child apps as subcommands. This mirrors how git remote add or docker compose up work.

python
import typer

app = typer.Typer()

# Sub-app for 'user' commands
users_app = typer.Typer()
app.add_typer(users_app, name="user")

@users_app.command("create")
def user_create(username: str, admin: bool = False):
    """Create a new user."""
    role = "admin" if admin else "regular"
    typer.echo(f"Created {role} user: {username}")

@users_app.command("delete")
def user_delete(username: str):
    """Delete a user."""
    typer.echo(f"Deleted user: {username}")

if __name__ == "__main__":
    app()

Usage:

bash
python main.py user create alice --admin
python main.py user delete bob

Output:

text
Created admin user: alice
Deleted user: bob

Enums and type coercion

Typer automatically accepts Enum members as valid values for an option or argument and shows them in help text.

python
import typer
from enum import Enum

class LogLevel(str, Enum):
    debug   = "debug"
    info    = "info"
    warning = "warning"
    error   = "error"

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

app = typer.Typer()

@app.command()
def run(
    level: LogLevel = LogLevel.info,
    fmt: OutputFormat = OutputFormat.text,
):
    """Run the application."""
    typer.echo(f"Log level: {level.value}, format: {fmt.value}")

if __name__ == "__main__":
    app()

Usage:

bash
python main.py --level debug --fmt json

Output:

text
Log level: debug, format: json

Prompts and confirmations

python
import typer

app = typer.Typer()

@app.command()
def delete_db():
    """Drop and recreate the database."""
    confirmed = typer.confirm("This will delete ALL data. Are you sure?")
    if not confirmed:
        typer.echo("Aborted.")
        raise typer.Exit()

    name = typer.prompt("Enter a name for the backup file", default="backup.sql")
    typer.echo(f"Creating backup: {name}")
    typer.echo("Database reset complete.")

if __name__ == "__main__":
    app()

Output:

text
This will delete ALL data. Are you sure? [y/N]: y
Enter a name for the backup file [backup.sql]:
Creating backup: backup.sql
Database reset complete.

Callbacks — version flags and global options

A callback function on @app.callback() runs before any subcommand. Use it for --version, global --verbose, or shared context setup.

python
import typer
from typing import Optional

app = typer.Typer()

def version_callback(value: bool):
    if value:
        typer.echo("myapp version 1.2.0")
        raise typer.Exit()

@app.callback()
def main(
    version: Optional[bool] = typer.Option(
        None, "--version", "-v", callback=version_callback, is_eager=True
    ),
):
    """My Application — does amazing things."""

@app.command()
def run():
    """Run the main operation."""
    typer.echo("Running...")

if __name__ == "__main__":
    app()

Usage:

bash
python main.py --version

Output:

text
myapp version 1.2.0

Rich output

With typer[all] installed, use Rich directly alongside Typer for coloured, formatted output.

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

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

@app.command()
def list_users():
    """Display user table."""
    table = Table(title="Users")
    table.add_column("Name", style="cyan")
    table.add_column("Role", style="magenta")
    table.add_column("Active", style="green")

    table.add_row("Alice Dev",   "admin",   "✓")
    table.add_row("Bob Builder", "editor",  "✓")
    table.add_row("Carol Tester","viewer",  "✗")

    console.print(table)

if __name__ == "__main__":
    app()

Output:

text
           Users
┏━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┓
┃ Name         ┃ Role   ┃ Active ┃
┡━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━┩
│ Alice Dev    │ admin  │ ✓      │
│ Bob Builder  │ editor │ ✓      │
│ Carol Tester │ viewer │ ✗      │
└──────────────┴────────┴────────┘

Testing

Typer exposes a CliRunner (via Click's test utilities) for unit-testing commands without spawning a process.

python
from typer.testing import CliRunner
from main import app   # import your Typer app

runner = CliRunner()

def test_greet():
    result = runner.invoke(app, ["Alice", "--times", "2"])
    assert result.exit_code == 0
    assert result.output.count("Hello, Alice!") == 2

def test_greet_help():
    result = runner.invoke(app, ["--help"])
    assert "NAME" in result.output
    assert "--times" in result.output

def test_missing_required():
    result = runner.invoke(app, [])
    assert result.exit_code != 0
    assert "Missing argument" in result.output

Quick reference

TaskCode
Single-function CLItyper.run(fn)
Multi-command appapp = typer.Typer() then @app.command()
Required argumentdef cmd(name: str)
Optional optiondef cmd(name: str = "default")
Explicit optiontyper.Option(default, "--flag", "-f", help="...")
Explicit argumenttyper.Argument(..., help="...")
Enum choicesclass X(str, Enum): ... as parameter type
Confirmationtyper.confirm("Sure?")
Prompttyper.prompt("Enter value")
Version flagcallback=version_callback, is_eager=True
Sub-appapp.add_typer(child_app, name="sub")
TestCliRunner().invoke(app, ["arg", "--flag"])
Exit with coderaise typer.Exit(code=1)
Abortraise typer.Abort()