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#
[!WARNING]
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.
[!WARNING] 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.
[!TIP] 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.
[!TIP]
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() |