Early Typer code declared options by putting typer.Option(...) in the default value: retries: int = typer.Option(3, "--retries", min=0). It works, but the real default is hidden inside a function call, type checkers see the parameter's default as a Typer object rather than an int, and the function cannot be called directly from Python without Typer supplying the values. Since version 0.9, Typer supports — and its documentation recommends — the Annotated style: retries: Annotated[int, typer.Option(min=0)] = 3. The type and the CLI metadata travel together in the annotation, and the default is an ordinary Python default. This guide shows the style for arguments and options, the reusable type aliases it makes possible, the handful of gotchas, and how to migrate an existing Typer app. It belongs to the Typer vs Click topic.
Prerequisites
- Python 3.10+ (for
X | None;typing.Annotateditself exists from 3.9) and Typer 0.12 or newer. - A Typer app, new or existing.
The two styles side by side
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer()
# Older style: the default is inside typer.Option(...)
@app.command()
def old(
site: Path = typer.Argument(..., exists=True, file_okay=False, help="Directory to deploy."),
retries: int = typer.Option(3, "--retries", "-r", min=0, max=10, help="Retry attempts."),
) -> None:
...
# Annotated style: type + metadata in the annotation, default after =
@app.command()
def new(
site: Annotated[Path, typer.Argument(exists=True, file_okay=False, help="Directory to deploy.")],
retries: Annotated[int, typer.Option("--retries", "-r", min=0, max=10, help="Retry attempts.")] = 3,
) -> None:
...
Both produce identical command-line behaviour and help. The differences are in the code:
- The default is visible where Python programmers look for it, after
=. A required argument simply has no default, instead of the...sentinel. - Type checkers see real types. mypy and pyright understand that
retriesis anintdefaulting to3. In the old style they see a default of typeOptionInfo, so type errors around defaults go unnoticed — covered further in type-checking Click and Typer code with mypy. - The function is callable as plain Python.
new(Path("site"))works in a test or from another module, using real defaults. Callingold(Path("site"))would pass anOptionInfoobject asretries.
The recipe: a command in Annotated style
# src/mytool/cli.py
from enum import Enum
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer(no_args_is_help=True)
class Env(str, Enum):
dev = "dev"
staging = "staging"
prod = "prod"
@app.callback()
def main() -> None:
"""Deployment tool."""
@app.command()
def deploy(
site: Annotated[Path, typer.Argument(exists=True, file_okay=False, help="Directory to deploy.")],
env: Annotated[Env, typer.Option(help="Target environment.")] = Env.dev,
retries: Annotated[int, typer.Option(min=0, max=10, envvar="MYTOOL_RETRIES",
help="Retry attempts.")] = 3,
tags: Annotated[list[str] | None, typer.Option("--tag", help="Tag the release (repeatable).")] = None,
dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="Show what would happen.")] = False,
) -> None:
"""Deploy SITE to an environment."""
tags = tags or []
typer.echo(f"deploy {site.name} to {env.value} retries={retries} tags={tags} dry_run={dry_run}")
if __name__ == "__main__":
app()
A few details worth knowing:
- Parameter names become option names.
dry_runbecomes--dry-runautomatically; pass explicit names ("--dry-run", "-n") when you want a short flag or a different spelling. - Booleans become flag pairs (
--dry-run/--no-dry-run) unless you give explicit names, as above, which produce a single flag. - Lists are repeatable options.
list[str] | None = Noneaccepts--tag a --tag b. UseNonerather than[]as the default, to avoid a mutable default argument. - Enums become choices, listed in
--helpand validated automatically. envvar=,min=/max=,exists=and every other option setting work exactly as before; they just live inside the annotation.
The recipe: reusable option aliases
The biggest practical win of Annotated is that an annotated type is a value you can name and reuse. Define common options once:
# src/mytool/options.py
from typing import Annotated
import typer
Verbose = Annotated[int, typer.Option("--verbose", "-v", count=True, help="More output (repeatable).")]
Limit = Annotated[int, typer.Option("--limit", "-n", min=1, help="Show at most N items.")]
Json = Annotated[bool, typer.Option("--json", help="Machine-readable output.")]
Yes = Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")]
from mytool.options import Json, Limit
@app.command("list")
def list_sites(limit: Limit = 20, json: Json = False) -> None:
"""List sites."""
@app.command("builds")
def list_builds(limit: Limit = 50, json: Json = False) -> None:
"""List builds."""
Every command that uses Limit gets the same flag names, help text and validation, while each keeps its own default. This is the cleanest way to enforce the naming consistency described in naming commands and flags consistently, and it composes with the decorator approach in sharing common options across commands.
Aliases with shared validation
Because an alias is just a type, it can carry behaviour as well as names. A parser or callback inside the typer.Option(...) travels with the alias, so every command that uses it validates the value the same way:
import re
from typing import Annotated
import typer
def parse_duration(value: str | float) -> float:
if isinstance(value, (int, float)): # the Python default passes through the parser too
return float(value)
m = re.fullmatch(r"(\d+)([smh])", value)
if not m:
raise typer.BadParameter("use a number and a unit, e.g. 30s, 5m, 2h")
return float(m[1]) * {"s": 1, "m": 60, "h": 3600}[m[2]]
Timeout = Annotated[float, typer.Option("--timeout", parser=parse_duration, metavar="DURATION",
help="How long to wait, e.g. 30s or 5m.")]
A command declares timeout: Timeout = 30.0 and receives seconds as a float, however the user wrote it. When the accepted format changes, it changes in one place. For richer conversions with their own error messages and completion, a custom Click parameter type plugged in with click_type= does the same job; see writing custom Click parameter types.
Migrating an existing app
The conversion is mechanical and can be done one command at a time, since both styles coexist in one app:
- Move
typer.Option(...)/typer.Argument(...)intoAnnotated[type, ...]. - Take the first positional argument out of the
Option/Argumentcall — that was the default — and put it after=. - For required parameters, delete the
...and give no default. - Keep every other setting (names,
help,envvar,min, callbacks) inside the call unchanged. - Replace mutable defaults (
[]) withNoneand handle it in the body.
Run the command tests after each conversion; behaviour should be identical. A helpful extra check is diffing --help output before and after, which catches accidentally dropped settings.
UX considerations
Annotated changes the code, not the interface, but it improves the interface indirectly:
- Consistency by construction. Shared aliases make identical flags truly identical across commands, including help wording.
- Fewer default-related bugs. Because type checkers understand the defaults, mistakes like an
Optionalpath used without aNonecheck are caught before users hit them. - Easier reuse of command functions. Commands that are plain functions with plain defaults can be called from other commands or from a Python API without faking Typer's parameter objects.
Testing the behaviour
Test through CliRunner as usual, and — newly possible — call command functions directly as Python functions:
# tests/test_deploy.py
from pathlib import Path
from typer.testing import CliRunner
from mytool.cli import Env, app, deploy
runner = CliRunner()
def test_cli_defaults(tmp_path: Path):
result = runner.invoke(app, ["deploy", str(tmp_path)])
assert result.exit_code == 0
assert "to dev retries=3 tags=[] dry_run=False" in result.output
def test_repeatable_tags_and_env_var(tmp_path: Path):
result = runner.invoke(app, ["deploy", str(tmp_path), "--tag", "a", "--tag", "b"],
env={"MYTOOL_RETRIES": "5"})
assert "retries=5 tags=['a', 'b']" in result.output
def test_validation_still_applies(tmp_path: Path):
result = runner.invoke(app, ["deploy", str(tmp_path), "--retries", "99"])
assert result.exit_code == 2
def test_callable_as_plain_python(tmp_path: Path, capsys):
deploy(tmp_path, env=Env.prod) # real defaults, no Typer involved
assert "to prod retries=3" in capsys.readouterr().out
The last test would fail in the old style, because retries would default to an OptionInfo object rather than 3.
Conclusion
The Annotated style puts a parameter's type and its command-line metadata in one place and leaves the default where Python expects it. Type checkers see real types, command functions become callable as plain Python, and common options become reusable type aliases that keep flags consistent across a whole CLI. Behaviour and help output are unchanged, both styles coexist, and migration is a mechanical, command-by-command change — which makes it an easy improvement to adopt in any Typer codebase.
Frequently asked questions
Is the old style deprecated?
It still works and is not scheduled for removal, but Typer's documentation uses Annotated throughout and recommends it for new code. Mixing both in one app is fine during migration.
Can I put a default inside typer.Option and after = at the same time?
No — Typer raises an error if a default appears in both places, because it would be ambiguous. Put it after = only.
How do I declare a required option (not argument)?
Give it no default: token: Annotated[str, typer.Option(help="API token.")]. Typer makes it required and reports "Missing option '--token'" when it is absent. Consider whether it should be a positional argument instead.
Why does my option's parser receive the default value?
Click runs defaults through the same conversion as values typed on the command line, so a parser= function sees the Python default too — a float rather than a string, in the duration example above. Either make the parser accept already-converted values, as shown, or express the default in the command-line form ("30s"), which keeps conversion in one place but makes the annotation's type and the default's type differ.
Does Annotated work with callbacks and autocompletion?
Yes. callback=, autocompletion=, parser= and every other setting go inside the typer.Option(...) in the annotation, exactly as in the old style.