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
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
argparseboilerplate. - 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]vsstr = None— in Typer,Optional[str] = Nonemakes the option optional with no default prompt. Plainstr(no default) makes it a required positional argument. The distinction matters:def cmd(name: Optional[str] = None)creates--nameoption;def cmd(name: str)creates a requiredNAMEargument.
Single-command apps don't need
Typer()— if you only have one command, usetyper.run(fn)instead of@app.command(). Mixing both causes the decorator to be ignored.
Annotate with
typer.Option(...)ortyper.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 toprint()but integrates with Typer's test runner. For rich formatting, usefrom rich import printdirectly — 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
| Task | Code |
|---|---|
| Single-function CLI | typer.run(fn) |
| Multi-command app | app = typer.Typer() then @app.command() |
| Required argument | def cmd(name: str) |
| Optional option | def cmd(name: str = "default") |
| Explicit option | typer.Option(default, "--flag", "-f", help="...") |
| Explicit argument | typer.Argument(..., help="...") |
| Enum choices | class X(str, Enum): ... as parameter type |
| Confirmation | typer.confirm("Sure?") |
| Prompt | typer.prompt("Enter value") |
| Version flag | callback=version_callback, is_eager=True |
| Sub-app | app.add_typer(child_app, name="sub") |
| Test | CliRunner().invoke(app, ["arg", "--flag"]) |
| Exit with code | raise typer.Exit(code=1) |
| Abort | raise typer.Abort() |