Python Typer CLI: Commands, Arguments, and Options
python typer cli commands arguments and options: Build a Python Typer CLI with commands, arguments, and options using type hints. Covers syntax, defaults, validation,...
python typer cli commands arguments and options requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
Typer builds a command-line interface directly from a Python function signature. You declare parameters with type hints, and Typer generates the parsing, help text, and validation for you. For working with Python Typer CLI commands, arguments, and options, the practical result is that your CLI stays close to the code that implements it, with no separate argument-parsing layer to maintain.
Building a Minimal Typer CLI
Install Typer with pip:
pip install typer
The smallest useful CLI is a single function passed to typer.run():
import typer def main(name: str) -> None: typer.echo(f"Hello {name}") if __name__ == "__main__": typer.run(main)
typer.run() inspects main and maps its parameters to CLI input. The name parameter has no default value, so it becomes a required positional argument. The str type hint tells Typer to accept any text and reject missing input with a clear error. typer.echo() prints to stdout and handles terminal encoding consistently.
A parameter with a default value behaves differently: it becomes an option instead of an argument. That single rule, default or no default, is the foundation for how Typer separates arguments from options.
Defining Multiple Commands
For more than one operation, create a typer.Typer() instance and decorate each function with @app.command():
import typer app = typer.Typer() @app.command() def add(a: int, b: int) -> None: typer.echo(a + b) @app.command() def greet(name: str, formal: bool = False) -> None: if formal: typer.echo(f"Good day, {name}.") else: typer.echo(f"Hi {name}!") if __name__ == "__main__": app()
Each decorated function becomes a subcommand. If the file is saved as cli.py, you invoke it as python cli.py add 2 3 or python cli.py greet Ada. Typer converts function names to kebab-case by default, so a function named send_email becomes the command send-email. The formal parameter defaults to False, so it becomes a --formal / --no-formal flag rather than a positional value.
app() at the bottom runs the CLI, parses sys.argv, and dispatches to the matching command. Typer also generates --help output for the app and for each command automatically.
Working with Arguments
Arguments are positional values that appear after the command name. A parameter without a default is required; a parameter with a default is optional and must be declared after required parameters.
import typer def main( source: str, destination: str = typer.Argument(help="Output path"), ) -> None: typer.echo(f"Copy from {source} to {destination}")
Both source and destination are required here. typer.Argument(help=...) adds help text without changing requiredness, because no default value was supplied. If you want an optional argument with a fallback, pass the default explicitly:
def main( source: str, destination: str = typer.Argument("out.txt", help="Output path"), ) -> None: typer.echo(f"Copy from {source} to {destination}")
Now destination can be omitted and defaults to out.txt. Python's own signature rules apply: required positional parameters must come before optional ones, so Typer inherits that constraint and reports a clear error if you violate it.
Configuring Options
Options are named flags and values that start with one or two dashes. In Typer, any parameter with a default value becomes an option.
import typer def main( name: str, greeting: str = typer.Option("Hello", help="Greeting to use"), uppercase: bool = False, ) -> None: message = f"{greeting}, {name}" if uppercase: message = message.upper() typer.echo(message)
greeting becomes --greeting with a default of Hello. uppercase is a bool, so Typer generates both --uppercase and --no-uppercase flags. This is the standard way to implement boolean switches without requiring a value.
To change the option name or add a short alias:
def main( name: str, count: int = typer.Option(1, "--count", "-c", min=1, max=10), ) -> None: typer.echo(f"{name} repeated {count} times")
The first string after the default is the long name, and additional strings are aliases. min and max are validation constraints that Typer applies during parsing, so invalid values fail before your function runs.
Type Conversion and Validation
Type hints control how Typer converts raw strings into Python values. Integers, floats, booleans, and pathlib.Path work directly. For a fixed set of choices, use an Enum:
import typer from enum import Enum class LogLevel(str, Enum): debug = "debug" info = "info" warning = "warning" error = "error" def main(level: LogLevel = LogLevel.info) -> None: typer.echo(f"Log level: {level.value}")
Typer builds a choice list from the Enum members and rejects anything outside it. Because LogLevel inherits from str, the values serialize cleanly and compare naturally with strings.
To accept multiple values for one option, annotate it with List[str]:
from typing import List def main(items: List[str]) -> None: for item in items: typer.echo(item)
The option can be repeated, and Typer collects the values into a list. The same pattern works with List[int] or List[Path], and each element is converted according to the inner type.
String and numeric constraints are declared directly on the parameter:
def main( port: int = typer.Option(8080, min=1, max=65535), name: str = typer.Option(..., min_length=2, max_length=50), ) -> None: typer.echo(f"Starting on {port} for {name}")
typer.Option(...) with an explicit ellipsis means the option is required even though it is an option. min_length and max_length apply to strings; min and max apply to numbers. Keeping validation in the signature means the function body never sees invalid input.
Error Handling and Exit Codes
Typer does not force a particular error style. You can print to stderr and set the process exit code explicitly:
import typer app = typer.Typer() @app.command() def process(path: str) -> None: if not path.endswith(".csv"): typer.echo("File must be a CSV", err=True) raise typer.Exit(code=1) typer.echo(f"Processing {path}") if __name__ == "__main__": app()
typer.echo(..., err=True) writes to stderr instead of stdout, which keeps normal output and diagnostics separate in shell pipelines. typer.Exit(code=1) stops execution immediately and sets the process exit code. For a simpler abort that prints Aborted and exits with code 1, raise typer.Abort().
Validation failures raised by Typer itself also produce non-zero exit codes and readable messages, so you do not need to wrap every parameter in a try/except.
Structuring a Larger CLI for Maintainability
As a CLI grows, keep command groups in separate modules and attach them to a shared app with add_typer:
# cli.py import typer from commands import users, reports app = typer.Typer() app.add_typer(users.app, name="users") app.add_typer(reports.app, name="reports") if __name__ == "__main__": app()
# commands/users.py import typer app = typer.Typer() @app.command() def list() -> None: typer.echo("user list")
The nested app becomes a command group, so the invocation is python cli.py users list. Each module owns its commands, its validation constraints, and its help text, which keeps the type-hint signatures readable and testable. Because Typer is built on Click, you can also mix in Click decorators and callbacks when you need behavior that type hints do not express, such as a callback that runs before a command.
The main maintainability tradeoff is that the type-hint signature is the source of truth for the interface. Keep parsing and validation in the signature rather than inside the function body, and keep side effects in the command functions. That separation makes each command predictable, easy to test by calling the function directly, and easy to extend when a new argument or option is added.