73.3 typer: Type-Annotated CLIs With Automatic Help
Right, so you’ve wrestled with argparse and maybe even flirted with click. You appreciate their power but are tired of the boilerplate, the decorator soup, or the sheer number of classes you have to instantiate just to ask for a username. Enter typer. This library is the brilliant, type-obsessed friend who looks at your function signatures and says, “Say no more, I got this.” It builds on click but uses Python’s type hints to do almost all the heavy lifting for you. The result is a CLI that feels almost magical, where you’re mostly just writing a normal Python function and getting a full-fledged command-line interface for free.
The Core Magic: It’s Just Type Hints
The entire philosophy of typer is that your function’s parameters are your CLI arguments and options. The type annotations you (should be) using anyway tell typer everything it needs to know. Let’s start stupidly simple.
# simple_cli.py
import typer
app = typer.Typer()
@app.command()
def greet(name: str):
"""A simple greeting app."""
typer.echo(f"Hello {name}")
if __name__ == "__main__":
app()
Run it:
$ python simple_cli.py --help
Usage: simple_cli.py [OPTIONS] NAME
A simple greeting app.
Arguments:
NAME [required]
Options:
--install-completion [bash|zsh|fish|powershell|pwsh]
Install completion for the specified shell.
--show-completion [bash|zsh|fish|powershell|pwsh]
Show completion for the specified shell.
--help Show this message and exit.
$ python simple_cli.py World
Hello World
See what happened? name: str became a required positional argument (NAME). If you want an optional --name flag instead, you just give it a default value. typer assumes parameters with no default are arguments and parameters with a default are options.
@app.command()
def greet(name: str = typer.Argument(...), formal: bool = False):
if formal:
typer.echo(f"Good day, Ms. {name}.")
else:
typer.echo(f"Hey {name}!")
Here, name is still a required argument (we’re using typer.Argument(...) to make that explicit, which is good practice), and formal becomes a --formal flag. No action='store_true' nonsense. Just a bool defaulting to False.
Going Beyond Strings and Bools
This is where it gets powerful. Want a --count option that takes an integer?
@app.command()
def hype(item: str, count: int = 1):
for _ in range(count):
typer.echo(f"{item.upper()} IS GREAT!")
Run with python hype.py pizza --count 3. typer handles the parsing and conversion to an int for you. Pass a string? It yells at the user with a clean error: Error: Invalid value for '--count': 'three' is not a valid integer. Beautiful.
You can use float, list, and even enum.Enum for a fixed set of choices. It’s all just standard Python types.
The Power of typer.Option() and typer.Argument()
Sometimes a type hint isn’t enough. You need a help string, or a different CLI name, or a callback function. This is where you use typer.Option() and typer.Argument() as the default value. Don’t be fooled by the syntax; it’s not a type. You’re using the function to configure the option/argument while providing the default.
from pathlib import Path
@app.command()
def deploy(
config_file: Path = typer.Option(
"default.conf", # Default value
"--config", "-c", # Different CLI names
help="Path to the config file. Will be created if it doesn't exist.",
exists=False, # Don't validate file existence
),
force: bool = typer.Option(False, "--force", "-f", help="Overwrite everything. You've been warned."),
):
"""Deploy something based on a config file."""
if config_file.exists():
typer.echo(f"Loading config from {config_file}")
else:
typer.echo(f"Creating new config at {config_file}")
if force:
typer.echo("Proceeding with extreme prejudice.")
This gives you a richly documented --help output with clear, specific text for each option.
The One Big Gotcha: Mutable Defaults
This is the biggest “trap” for new typer (and Python) users. Never use a mutable object as a default value directly. You know the drill: def bad_idea(files: list[str] = []): is a recipe for debugging pain.
typer gives you a specific tool to avoid this: typer.Option() with a default_factory.
# WRONG: Don't do this!
@app.command()
def log(message: str, history: list[str] = []):
history.append(message)
typer.echo(f"History is now: {history}")
# RIGHT: Do this instead.
@app.command()
def log(message: str, history: list[str] = typer.Option(default_factory=list)):
history.append(message)
typer.echo(f"History is now: {history}")
The default_factory is a function that returns a new instance of your mutable object every time the command is called. This is a typer best practice you must internalize.
Why Rich is Its Best Friend
typer is built by the same genius behind rich. They are soulmates. This means you get stunning, formatted output and tracebacks for free. Notice we used typer.echo instead of print? That’s because it’s rich-powered. Errors are colorized, and help pages are beautifully formatted. It makes your CLI feel professional and modern with zero extra effort. It’s the best argument for using typer over more established options.
In essence, typer is for developers who think in functions and type hints first. It removes the ceremony and lets you focus on the logic. It’s not just easier to write; it’s often easier to read and maintain because your CLI definition is just a beautifully annotated function signature. It’s one of those libraries that genuinely makes Python feel more magical.