A CLI is a boundary program: almost everything it handles arrives untyped. Arguments are strings, configuration files become dictionaries, API responses become json.loads() results, environment variables are str | None. Type checkers in their default modes let most of that untyped data flow freely through the program, so the bug where row["status"] is sometimes an integer surfaces in production, not in CI. Pyright's strict mode refuses to let unknown types spread: every parameter must be annotated, and any value whose type the checker cannot determine is an error where it is used. That is exactly the discipline a CLI needs — validate untyped input once, at the edge, and work with real types everywhere else. This guide sets up strict mode, rolls it out gradually, shows the patterns that satisfy it without drowning in casts, and runs it in CI. It belongs to the linting and type checking topic; the mypy side is in type-checking Click and Typer code with mypy.
Prerequisites
- A CLI project with type hints in at least part of the code.
- Pyright, run with
uvx pyright(it downloads a Node.js runtime if needed) or thebasedpyrightfork, which ships as a pure Python wheel and adds a few stricter rules. - The project installed in a virtual environment, so Pyright can see its dependencies.
What strict mode changes
Pyright's basic and standard modes report clear errors — calling a method that does not exist, passing a str where an int is required. strict mode turns on a family of rules about unknown types: missing parameter annotations, variables and members whose types are partially unknown, and untyped functions called from typed code.
The important distinction is between Any and Unknown. Any is a type you (or a library stub) wrote deliberately, and strict mode accepts it. Unknown is what Pyright infers when it has nothing to go on — an unannotated parameter, an empty {} literal, a library without type information — and strict mode reports wherever an Unknown is used. A CLI's untyped input mostly arrives as Any (from json.loads, yaml.safe_load) and becomes Unknown the moment it is stored in an unannotated container or passed to an unannotated function.
The recipe
Configure
# pyproject.toml
[tool.pyright]
include = ["src"]
pythonVersion = "3.11"
typeCheckingMode = "strict"
venvPath = "."
venv = ".venv"
include keeps tests and scripts out of the first pass; venvPath and venv point Pyright at the project environment so imports resolve. Run it:
uvx pyright
Here is what strict mode says about a small, plausible-looking module that reads a JSON report:
import json
from pathlib import Path
def fmt(row):
return f"{row['name']}: {row['status']}"
def summarise(path: Path) -> list[str]:
rows = json.loads(path.read_text())
counts = {}
for row in rows:
counts[row["status"]] = counts.get(row["status"], 0) + 1
return [fmt(r) for r in rows]
report.py:5:9 - error: Type of parameter "row" is unknown (reportUnknownParameterType)
report.py:5:9 - error: Type annotation is missing for parameter "row" (reportMissingParameterType)
report.py:13:33 - error: Type of "get" is partially unknown (reportUnknownMemberType)
3 errors, 0 warnings, 0 informations
Each error points at a place where the code's assumptions about the data are invisible to the checker — and to the next reader.
Fix at the boundary, not everywhere
The tempting fix is to sprinkle Any and cast() until the errors stop. The better fix is to convert untyped input into typed values once, where it enters the program, and let everything downstream use real types:
# src/mytool/report.py
import json
from collections import Counter
from pathlib import Path
from typing import TypedDict, cast
class Row(TypedDict):
name: str
status: str
def parse_rows(raw: object) -> list[Row]:
"""Validate untyped JSON once, at the boundary."""
if not isinstance(raw, list):
raise ValueError("expected a JSON array")
rows: list[Row] = []
for item in cast(list[object], raw):
if not isinstance(item, dict):
raise ValueError("expected objects in the array")
obj = cast(dict[str, object], item)
name, status = obj.get("name"), obj.get("status")
if not isinstance(name, str) or not isinstance(status, str):
raise ValueError("each row needs string 'name' and 'status'")
rows.append({"name": name, "status": status})
return rows
def fmt(row: Row) -> str:
return f"{row['name']}: {row['status']}"
def summarise(path: Path) -> tuple[list[str], Counter[str]]:
rows = parse_rows(json.loads(path.read_text(encoding="utf-8")))
counts = Counter(row["status"] for row in rows)
return [fmt(r) for r in rows], counts
parse_rows takes object — the honest type of anything — and narrows it with isinstance checks, which Pyright understands. The two cast() calls are the only places the code asserts something the checker cannot prove, and both sit directly after the isinstance check that justifies them. Everything after parse_rows works with Row, and a typo such as row["stauts"] is now a type error. For larger schemas, a Pydantic model or msgspec struct does the same validation with less code, as in typed settings with pydantic-settings.
Typer and Click under strict mode
Typer's Annotated style is fully typed and passes strict mode without help: parameters are annotated, and typer.Option(...) metadata lives inside Annotated. Click decorators are typed too, but command callbacks must annotate every parameter, and values from ctx.obj are Any — wrap them in a typed accessor (def get_config(ctx: click.Context) -> Config: return cast(Config, ctx.obj)) rather than reading ctx.obj everywhere. The patterns in sharing state with Click context objects fit naturally.
Roll out gradually
Turning strict mode on for a large codebase at once produces hundreds of errors and a tempting # type: ignore spree. Two gentler routes:
[tool.pyright]
typeCheckingMode = "standard"
strict = ["src/mytool/core", "src/mytool/config.py"]
The strict list applies strict rules only to the listed paths, so new or important modules can be strict while the rest catches up. Alternatively, add # pyright: strict as the first comment in a file to opt that file in. Grow the strict set module by module; it is satisfying, visible progress, and each module you convert stays converted because CI enforces it.
UX considerations
The users of a type checker are the people who maintain the code:
- Prefer narrowing to casting.
isinstancechecks are verified by the checker and fail loudly at runtime;castis a promise with no enforcement. Keep casts next to the check that justifies them. - Make
# pyright: ignore[rule]specific. A bare ignore hides every future error on that line; naming the rule documents what was suppressed. - Keep the noisy rules on.
reportUnknownMemberTypeis the rule people most want to disable, and the one that finds untyped data leaking from dependencies. Fix the boundary instead. - Run it where people code. Pylance in VS Code uses the same engine, so errors appear while typing — set the workspace to the same configuration as CI.
Testing the behaviour
Type checking is itself a test, so the "test" here is making CI enforce it with the same configuration everyone uses locally:
# .github/workflows/types.yml (excerpt)
- uses: astral-sh/setup-uv@v6
- run: uv sync --locked
- run: uvx pyright@1.1.414 --outputjson > pyright.json || true
- run: uvx pyright@1.1.414
Pin the Pyright version: new releases add checks, and a red build caused by a checker upgrade should be a deliberate pull request, not a surprise on an unrelated change. Pair it with runtime tests of the boundary functions — parse_rows with a non-list, a list containing a number, a row with an integer status — because the type checker proves the downstream code is consistent only if the boundary really rejects bad input. Property-based testing CLI arguments with Hypothesis is a good way to throw unexpected shapes at it.
Conclusion
Strict mode suits CLIs because their hardest bugs come from untyped input leaking into typed code. Enable typeCheckingMode = "strict" (or a strict list of paths to start), convert JSON, YAML and environment data into typed structures once at the boundary with narrowing, keep casts rare and justified, and pin the Pyright version in CI. The errors stop being noise and start pointing at exactly the assumptions that would have failed in production.
Frequently asked questions
Pyright or mypy?
Both are good. Pyright is fast, has the strict "unknown type" rules built in, and powers editor feedback in VS Code; mypy has a plugin system (for example for Pydantic and SQLAlchemy) and a long history in many codebases. Running both is common in libraries; for a CLI, pick one and enforce it.
What is basedpyright?
A community fork of Pyright distributed as a Python package (no Node.js needed), with extra rules and a "baseline" feature that records existing errors so only new ones fail CI — handy for adopting strict mode in a large codebase.
Why does Pyright complain about a library I use?
The library ships without type information, so its functions return Unknown. Look for a stubs package (types-…), check whether Pyright's bundled typeshed covers it, or wrap the calls you use in a small typed module of your own so the Unknown stops at that boundary.
Should tests be type-checked strictly?
Usually in standard mode. Tests poke at internals and use fixtures in ways that strict rules make tedious; the value of strict checking is in the shipped code.