Project Setup

Type-Checking Click and Typer Code with mypy

Get real type safety in Python CLIs: mypy strict settings, Annotated Typer parameters, typed Click commands, a typed ctx.obj, Enums for choices and tests.

Updated

A command-line tool is where untyped data enters your program: every argument starts as a string in sys.argv. Typer and Click convert those strings into Python values, and from that point on a type checker can verify that each value is used correctly — that the optional --out path is checked for None before use, that a --retries count is not concatenated to a string, that the settings object shared between commands has the attributes you think it has. In practice, CLI codebases often leak Any at exactly the framework boundary and lose most of that protection. This guide sets up mypy for a Typer or Click project and closes the usual gaps: parameters annotated with Annotated, typed Click commands, a typed context object, and Enums instead of bare strings for choices. It is part of the linting and type-checking topic.

Prerequisites

  • Python 3.10+, Typer 0.12+ or Click 8.1+, and mypy as a pinned dev dependency (uv add --dev mypy).
  • A project in src/ layout. Pyright works equally well with everything below; only the configuration syntax differs.

How types flow through a CLI

Values cross three stages. The shell passes strings; the framework converts them according to the parameter declarations; your command function receives Python objects and passes them to the rest of the program.

Types across the CLI boundary Values arrive as strings on the command line, Typer converts them according to annotations, and typed core functions receive checked Python types. Types across the CLI boundary argv strings "3", "./out" Typer annotations int, Path Command function typed params Core functions fully typed converted checked called Annotations do double duty in Typer: they drive parsing at runtime and type-checking statically.

In Typer the declaration is the type annotation, so the conversion rules and the static types cannot drift apart. In Click, declarations live in decorators (@click.option("--retries", type=int)) and the function parameters are separate — the type checker sees only the function signature, so it is your job to annotate it consistently with the decorator.

The recipe: mypy configuration

# pyproject.toml
[tool.mypy]
python_version = "3.10"       # your oldest supported Python
files = ["src", "tests"]
strict = true
warn_unreachable = true
enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]

[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_defs = false
disallow_incomplete_defs = false

strict enables the checks that matter — no untyped definitions, no implicit Optional, no returning Any from typed functions — and python_version makes mypy flag standard-library APIs newer than your oldest supported interpreter. Tests are checked, but not required to be fully annotated, so calls into your code are still verified without annotating every fixture.

The recipe: typed Typer commands

Use Annotated for every parameter. It keeps the real default in the place Python (and mypy) expects, and puts Typer's metadata alongside the type:

# src/mytool/cli.py
from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Annotated

import typer

app = typer.Typer()


class Format(str, Enum):
    table = "table"
    json = "json"


@dataclass
class State:
    verbose: bool
    config_path: Path


def state(ctx: typer.Context) -> State:
    """The one place ctx.obj (typed Any by Click) becomes a real type."""
    obj = ctx.find_object(State)
    if obj is None:
        raise RuntimeError("State not initialised by the app callback")
    return obj


@app.callback()
def main(
    ctx: typer.Context,
    verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
    config: Annotated[Path, typer.Option(envvar="MYTOOL_CONFIG")] = Path("mytool.toml"),
) -> None:
    """Report tools."""
    ctx.obj = State(verbose=verbose, config_path=config)


def render(rows: list[dict[str, str]], fmt: Format, out: Path) -> int:
    ...
    return len(rows)


@app.command()
def report(
    ctx: typer.Context,
    source: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
    fmt: Annotated[Format, typer.Option("--format", "-f")] = Format.table,
    out: Annotated[Path | None, typer.Option("--out", "-o")] = None,
    limit: Annotated[int, typer.Option(min=1)] = 50,
) -> None:
    """Render a report from SOURCE."""
    st = state(ctx)
    rows = [{"line": line} for line in source.read_text(encoding="utf-8").splitlines()[:limit]]
    target = out if out is not None else source.with_suffix(f".{fmt.value}")
    written = render(rows, fmt, target)
    if st.verbose:
        typer.echo(f"wrote {written} rows to {target}", err=True)


if __name__ == "__main__":
    app()

Three details carry the type safety:

Path | None for optional options. mypy then refuses render(rows, fmt, out) directly, because out might be None; you must decide what None means, as the target line does. Without the annotation — or with a default of None on a parameter typed Path — the bug ships and appears only when a user omits --out.

An Enum for choices. Typer turns an Enum parameter into a choice option with the members as allowed values, and your code receives Format.json instead of "json". mypy can then check exhaustive handling, and a typo in a comparison (fmt == "jsno") becomes impossible.

A typed accessor for ctx.obj. Click types the context object as Any, and Any spreads silently: every attribute access on it is unchecked. Funnel all access through one function that returns a concrete type. ctx.find_object(State) walks up the context chain and returns an instance of that class or None, so the accessor works from nested subcommands too — the pattern from sharing state with Click context objects, made type-safe.

Typing Click and Typer code How type checkers see common Click and Typer patterns and what to do to keep them checkable. Typing Click and Typer code Pattern Typing status Do this Typer params with annotations fully typed use Annotated[...] Click decorated function params untyped by decorator annotate params yourself ctx.obj Any cast to a typed dataclass once click.Choice values str convert to an Enum The context object is the usual leak where Any spreads through a CLI codebase.

The recipe: typed Click commands

Click's decorators return an untyped Command object, so mypy checks the body of your function against its own annotations. Annotate every parameter to match the decorator:

# src/mytool/click_cli.py
from __future__ import annotations

from pathlib import Path

import click


@click.command()
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--retries", type=int, default=3, show_default=True)
@click.option("--out", type=click.Path(path_type=Path), default=None)
def fetch(source: Path, retries: int, out: Path | None) -> None:
    """Fetch items listed in SOURCE."""
    target = out or source.with_suffix(".out")
    click.echo(f"{retries} retries -> {target}", err=True)

path_type=Path makes Click actually pass a Path (without it, you get a str despite the annotation — a mismatch mypy cannot see). The same care applies to type=click.Choice([...]), which passes a str; annotate it as str, or convert it to an Enum on the first line. Keeping annotations honest to the decorators is exactly what the tests below are for.

UX considerations

Types are for developers, but they have direct effects on users:

  • Optional flags handled correctly. The most common crash in untyped CLI code is a None from an omitted option; strict optional checking prevents it.
  • Help text from Enums. Enum-typed options list their allowed values in --help automatically, and invalid values produce a clear "Invalid value for '--format'" error rather than a failure deep inside the code.
  • Refactors that do not break commands. Renaming a field on the shared State object is caught in every command at once instead of at runtime in the one command nobody tested.
  • Clear mypy errors, not suppressions. When mypy complains about framework code, prefer a precise cast in the accessor or a narrowly scoped # type: ignore[code] over loosening global settings.
mypy catching a CLI bug Terminal output of mypy reporting that a command passes an optional path to a function that requires a path. mypy catching a CLI bug bash $ uv run mypy src src/mytool/cli.py:31: error: Argument 1 to "render" has incompatible type "Path | None"; expected "Path" [arg-type] Found 1 error in 1 file (checked 24 source files) The --out option defaults to None; without mypy, this crashes only when the user omits it.

Testing the behaviour

Two kinds of test keep the types honest. mypy itself runs in CI (uv run mypy). And a small runtime test verifies that what the framework actually passes matches the annotations — the thing mypy cannot see through Click decorators:

# tests/test_types.py
from pathlib import Path

from click.testing import CliRunner
from typer.testing import CliRunner as TyperRunner

from mytool import cli
from mytool.click_cli import fetch


def test_click_passes_what_we_annotated(tmp_path, monkeypatch):
    src = tmp_path / "items.txt"
    src.write_text("a\n")
    seen = {}
    monkeypatch.setattr(fetch, "callback",
                        lambda source, retries, out: seen.update(source=source, retries=retries, out=out))
    CliRunner().invoke(fetch, [str(src), "--retries", "5"])
    assert isinstance(seen["source"], Path)
    assert isinstance(seen["retries"], int)
    assert seen["out"] is None


def test_typer_enum_choice(tmp_path):
    src = tmp_path / "in.txt"
    src.write_text("x\ny\n")
    result = TyperRunner().invoke(cli.app, ["-v", "report", str(src), "--format", "json"])
    assert result.exit_code == 0, result.output
    assert "in.json" in result.output


def test_invalid_choice_is_a_usage_error(tmp_path):
    src = tmp_path / "in.txt"
    src.write_text("x\n")
    result = TyperRunner().invoke(cli.app, ["report", str(src), "--format", "xml"])
    assert result.exit_code == 2

The Click test replaces the command's callback with a recorder and asserts on the runtime types — it would fail immediately if someone removed path_type=Path while the annotation still said Path.

Conclusion

A CLI can be as well-typed as any other Python code once the framework boundary is handled deliberately. Turn on strict mypy, annotate Typer parameters with Annotated, give optional options X | None types and handle the None, use Enums for choices, annotate Click commands to match their decorators (with path_type=Path), and route ctx.obj through one typed accessor. The result is that most bugs involving the shape of user input are found by mypy before anyone runs the command.

Frequently asked questions

Does Typer's older param: int = typer.Option(3) style type-check?

mypy sees the default as whatever typer.Option returns, which is typed as Any, so it does not complain — but it also cannot tell you that a parameter with default None should be Optional. Annotated makes the default explicit and checkable, which is why it is the recommended style.

How do I type a Click group's ctx.obj without a dataclass?

A TypedDict works if you prefer dictionaries: define class Obj(TypedDict): verbose: bool and have the accessor return cast(Obj, ctx.obj). A dataclass is usually clearer and catches misspelt attributes at construction.

mypy says a Click decorator makes my function untyped. What now?

With disallow_untyped_decorators, mypy complains that Click's decorators are not fully typed in some versions. Keep the strict setting and add a targeted override for your CLI modules (disallow_untyped_decorators = false) rather than disabling it project-wide.

Does from __future__ import annotations break Typer?

Not with current Typer releases, which resolve string annotations with typing.get_type_hints. The one trap is annotating with a name that only exists under if TYPE_CHECKING: — Typer needs the real type at runtime to build the parameter, so import anything used in a command signature normally.

Should I use pydantic for option validation?

For complex, nested configuration — a config file, JSON arguments — pydantic models add validation and good error messages, as covered in typed settings with pydantic-settings. For individual flags, Typer's own types and callbacks are usually enough.