Architecture

Using Annotated Options in Typer

Declare Typer arguments and options with typing.Annotated: real defaults, type-checker friendly signatures, reusable option aliases, and migrating from the old style.

Updated

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.Annotated itself exists from 3.9) and Typer 0.12 or newer.
  • A Typer app, new or existing.

The two styles side by side

Default-value style versus Annotated A comparison of the older Typer style that puts typer.Option in the default value with the Annotated style, by readability, type checking and reuse. Default-value style versus Annotated Aspect = typer.Option(...) Annotated[..., typer.Option()] Real default visible inside Option(...) after =, as in Python Type checkers see Any see the real type Call as a function defaults are OptionInfo normal defaults Reusable type alias no yes Annotated is the style Typer's documentation recommends for new code.
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 retries is an int defaulting to 3. In the old style they see a default of type OptionInfo, 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. Calling old(Path("site")) would pass an OptionInfo object as retries.

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_run becomes --dry-run automatically; 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 = None accepts --tag a --tag b. Use None rather than [] as the default, to avoid a mutable default argument.
  • Enums become choices, listed in --help and 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."""
One alias, many commands A reusable Annotated type alias carries the type and the option metadata, and every command that uses it gets identical flags, help and validation. One alias, many commands Output alias Annotated, defined once list command output: Output show command output: Output Same --output everywhere reused reused consistent Type aliases turn interface conventions into code instead of copy-paste.

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:

  1. Move typer.Option(...) / typer.Argument(...) into Annotated[type, ...].
  2. Take the first positional argument out of the Option/Argument call — that was the default — and put it after =.
  3. For required parameters, delete the ... and give no default.
  4. Keep every other setting (names, help, envvar, min, callbacks) inside the call unchanged.
  5. Replace mutable defaults ([]) with None and 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:

The help Annotated produces Terminal output of help for a Typer command whose parameters are declared with Annotated, showing arguments, options, defaults and environment variables. The help Annotated produces bash $ mytool deploy --help Usage: mytool deploy [OPTIONS] SITE SITE Directory to deploy. [required] --env [dev|staging|prod] Target environment. [default: dev] --retries INTEGER RANGE [0<=x<=10] [env var: MYTOOL_RETRIES] [default: 3] The help output is identical to the older style; the difference is all in the code.
  • 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 Optional path used without a None check 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.