Skip to content

How to test a Python library against multiple type checkers

Tip

This assumes the library already ships annotations and uses a src/ layout. If a consumer’s mypy reports [import-untyped], start with how to ship type annotations with a Python library.

Check a library’s implementation with one type checker, then verify representative public API calls with the checkers its users run. This recipe uses mypy, Pyright, and Pyrefly for the API checks. Passing them verifies the examples checked, not every possible consumer program.

Add explicit public API type assertions

In existing tests that call the public API, use assert_type to check expected result types. Import it from typing on Python 3.11 or newer, or typing_extensions on older supported versions.

Annotate test functions or enable body checking. Otherwise mypy skips assertions inside completely unannotated test functions by default. Keep these checks focused on the annotations users consume.

Install nox and configure Pyrefly

uv tool install nox

uv installs the nox command in its tool environment. Check that the tool executable directory is on PATH.

Add an explicit Pyrefly configuration:

pyproject.toml
[tool.pyrefly]
preset = "default"
project-includes = ["src", "tests"]

An unconfigured Pyrefly run may migrate another checker’s settings in memory, or fall back to basic, which disables broad assignment and call-shape diagnostics. Explicit configuration keeps the intended policy visible. The sessions also pass --preset default to select the same base preset. Review any explicit per-rule overrides, which still apply.

Give each checker an isolated session

noxfile.py
import nox

nox.options.default_venv_backend = "uv"


@nox.session
def typecheck_src(session: nox.Session) -> None:
    session.install(".", "pyrefly")
    session.run("pyrefly", "check", "--preset", "default", "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", "--check-untyped-defs", "tests/")
    elif checker == "pyright":
        session.run("pyright", "tests/")
    else:
        session.run("pyrefly", "check", "--preset", "default", "tests/")

typecheck_src checks implementation types once. Replace Pyrefly with the project’s usual checker if needed. The API sessions install the built library and one checker each; add any test dependencies or extras that the project’s API examples import.

The example resolves current checker releases on each fresh session. For reproducible release gates, pin reviewed checker versions in the session requirements or use a committed constraints file; upgrade those pins deliberately.

Run the sessions locally

nox
nox -s typecheck_src
nox -s "typecheck_api(checker='mypy')"
nox -s "typecheck_api(checker='pyright')"
nox -s "typecheck_api(checker='pyrefly')"

The first command runs four sessions. Each subsequent command selects one. A clean run ends with successful session results; a checker failure makes its session and the nox run fail.

Add the same checks to CI

.github/workflows/typecheck.yml
name: Type check
on: [push, pull_request]
jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7.0.1
      - uses: astral-sh/setup-uv@v10.3.3
      - run: uv tool install nox
      - run: nox

Run the same sessions before a release. These sessions check API compatibility, but a checker’s source-layout discovery may still resolve local src/ files. Validate the built distribution’s annotation metadata separately with the annotation packaging recipe.

Resolve differences with narrow exceptions

First compare target Python versions, installed stub packages, inferred return types, and container element types. Add an explicit public annotation when it clarifies the API for every checker.

For an accepted exception, use a rule-specific suppression. mypy uses # type: ignore[assignment]; Pyright uses # pyright: ignore[reportAssignmentType]; Pyrefly uses # pyrefly: ignore[bad-assignment]. A bare # type: ignore affects several checkers and hides unrelated diagnostics on the line.

Start with the checkers consumers actually use. Add ty, Basedpyright, or Zuban sessions when those compatibility checks have a clear audience.

Last updated on