How to test a Python library against multiple type checkers
Tip
This assumes the library already ships its annotations. If a consumer’s mypy reports [import-untyped] against your package, start with how to ship type annotations with a Python library.
Python users don’t all run the same type checker. Some reach for mypy, others pyright or Pyrefly. Each sees a library’s public API through its own rules. What passes mypy can fail pyright; what works in Pyrefly can be rejected by mypy.
Running all five type checkers on source code is not the answer. That path leads to dozens of checker-specific # type: ignore comments scattered through internal files that users never see. Instead, focus on what users actually encounter: the public API.
The Pyrefly team’s recommendation for library maintainers: run one type checker on source code for internal consistency, and run multiple type checkers on the test suite. Tests call the library the way users do, so tests that type-check correctly under mypy, pyright, and Pyrefly confirm that users of all three can consume the API without error.
Structure the test suite
If the test suite already calls public functions and constructors, it already exercises the public API surface. To make the type assertions explicit, add assert_type calls. assert_type is available in typing from Python 3.11; for earlier versions, install typing_extensions:
from typing import assert_type # or: from typing_extensions import assert_type
import mylib
result = mylib.process("hello")
assert_type(result, str)
obj = mylib.Widget(name="test")
assert_type(obj.width, int)Each checker verifies that the inferred type matches the declared type. A mismatch is a type error.
Install nox
uv tool install noxGive Pyrefly a config table
Finding no pyrefly.toml and no [tool.pyrefly] table, Pyrefly falls back to its basic preset, which disables assignment and call-shape validation. It still parses the file and reports syntax errors, so the run looks normal:
$ pyrefly check tests/
INFO 0 errors
No `pyrefly.toml` found — using preset `basic`.
Run `pyrefly init` to continue setting up Pyrefly.
Docs: https://pyrefly.org/en/docs/installation/
Assignment and call-shape errors are most of what this workflow exists to catch, so both Pyrefly sessions exit 0 on code mypy and pyright reject. A pyproject.toml alone does not count. Add the table before writing the noxfile:
[tool.pyrefly]
project-includes = ["src", "tests"]Finding any config switches Pyrefly to its default preset, so an empty [tool.pyrefly] table works too. pyrefly init writes a standalone pyrefly.toml instead, and pyrefly check -p default gets the same checks with no config file at all.
Write the noxfile
import nox
nox.options.default_venv_backend = "uv"
@nox.session
def typecheck_src(session: nox.Session) -> None:
session.install(".", "pyrefly")
session.run("pyrefly", "check", "src/")
@nox.session
@nox.parametrize("checker", ["mypy", "pyright", "pyrefly"])
def typecheck_api(session: nox.Session, checker: str) -> None:
session.install(".", checker)
if checker == "mypy":
session.run("mypy", "tests/")
elif checker == "pyright":
session.run("pyright", "tests/")
else:
session.run("pyrefly", "check", "tests/")typecheck_src runs Pyrefly on src/ once. Swap in the checker you use day-to-day if you prefer a different one.
typecheck_api is parametrized: nox creates three separate sessions, each with its own virtual environment and checker installed. Failures in these sessions reveal public API types that checker’s users would hit.
Run locally
nox # run all sessions
nox -s typecheck_src # source check only
nox -s "typecheck_api(checker='mypy')" # API check with mypy
nox -s "typecheck_api(checker='pyright')" # API check with pyright
nox -s "typecheck_api(checker='pyrefly')" # API check with PyreflyAdd to GitHub Actions CI
name: Type Check
on: [push, pull_request]
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v8.1.0
- run: uv tool install nox
- run: noxAll four sessions run on every push. The parametrized typecheck_api session runs three times, once per checker.
Fix checker-specific failures
Failures in typecheck_api mean one checker rejects a type annotation that the others accept. Two causes to check first:
- Ambiguous container types:
x = []followed byx.append(1): pyright inferslist[Unknown], mypy inferslist[int]. Add an explicit annotation:x: list[int] = []. - Inferred
Nonevs explicitOptional: checkers disagree on when a missingreturnimplies-> None. Annotate return types explicitly.
When the only fix is a suppression comment, use the checker-specific format to keep the suppression targeted:
x = get_items() # type: ignore[assignment] # mypy / Zuban
x = get_items() # pyright: ignore[reportAssignmentType] # pyright / Basedpyright
x = get_items() # pyrefly: ignore # PyreflyStart with two checkers
Adding a third checker means another CI session to maintain. Start with mypy and pyright:
@nox.session
@nox.parametrize("checker", ["mypy", "pyright"])
def typecheck_api(session: nox.Session, checker: str) -> None:
session.install(".", checker)
if checker == "mypy":
session.run("mypy", "tests/")
else:
session.run("pyright", "tests/")Add Pyrefly when users ask for it or when it becomes standard in the library’s ecosystem.