skip to content

typer — CLI Apps with Type Hints

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

7 min read 29 snippets deep dive

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#

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

Output: (none — exits 0 on success)

Quick example#

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:

python main.py Alice --times 3

Output:

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

Output:

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#

[!WARNING] 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.

[!WARNING] 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.

[!TIP] 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.

[!TIP] 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.

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:

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

Output:

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.

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:

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

Output:

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).

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:

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

Output:

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.

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:

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

Output:

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.

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:

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

Output:

Log level: debug, format: json

Prompts and confirmations#

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:

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.

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:

python main.py --version

Output:

myapp version 1.2.0

Rich output#

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

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:

           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.

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()