Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Static Type Checking and Error Correction

Hello. In the previous lesson, you configured Ruff to keep formatting and linting consistent across the incident project. Ruff can spot an unused import or a mutable default, but it does not establish whether a function receives the kind of value it expects. This lesson adds mypy, a static type checker, to that quality gate.

You will run mypy against the installable incident_api package, interpret two realistic errors, and fix the underlying code rather than make the diagnostics disappear. Allow about 40 minutes, including the implementation checkpoint.


What a type-checker result means

Python still checks types at runtime. Adding annotations does not make input() return an integer, nor does mypy execute your incident code. Instead, mypy examines annotated code before it runs and reports operations that may violate the contracts you have declared.

Watch this short demonstration of running mypy, then its introduction to strict checking:

Static type checking with Mypy — Perfect Python

In “Static type checking with Mypy — Perfect Python,” Carberra demonstrates the command-line feedback loop and shows how a project can require annotated functions.

Watch the first run for the difference between checking unannotated and annotated code. Then watch strict configuration, focusing on the pyproject.toml setting and the newly reported missing annotation. We will use the project’s isolated environment rather than install mypy globally.

A successful check is useful only if mypy can see enough types to reason about your code. An unannotated function may conceal a mistake; a value of type Any can conceal one too. Any effectively tells the checker to permit operations whose safety it cannot establish. That makes it different from object, which requires you to inspect or narrow a value before using it in a type-specific way.

The mypy documentation explains these limitations directly:

Common issues and solutions - mypy 1.20.0 documentation

Read the mypy documentation to learn why “no errors” does not always mean “well checked,” and why ignoring a diagnostic has a cost.

In “No errors reported for obviously wrong code,” start with untyped functions. Continue through the example where an untyped parameter introduces Any, stopping after the paragraph about imprecise library stubs. Then, in “Spurious errors and locally silencing the checker,” read from the first # type: ignore example through the note recommending error-specific ignores; that note explains why a blanket ignore is risky. Stop before “Another option is to explicitly annotate values.”

The goal is not to obtain a green check by removing annotations. It is to make the check meaningful and green by aligning annotations, runtime values, and intended behavior.


Make mypy part of the project

Install mypy as a locked development dependency, just as you did with Ruff. If this project uses uv:

uv add --dev mypy

Keep its policy in the existing pyproject.toml. For a project whose oldest supported Python is 3.11, a useful starting point is:

[tool.mypy]
python_version = "3.11"
strict = true

Use your project’s actual minimum supported version if it differs; it should agree with project.requires-python and the Python version targeted by Ruff. strict = true enables a collection of checks, including checks for missing function annotations. It does not prove that every value is precisely typed or that your incident rules are correct.

From the repository root, check the installed package:

uv run mypy -p incident_api

Here, -p asks mypy to check the package and its submodules, not just the package’s __init__.py. This assumes the src-layout project is installed in the environment managed by uv, as established earlier in the course. If you use an activated virtual environment instead, run python -m mypy -p incident_api from that environment.

For more detail on choosing the check target, read the official guidance:

Running mypy and managing imports - mypy 1.19.1 documentation

The mypy documentation distinguishes checking a file, a module, and an entire package. It also explains why an unresolved import weakens checks beyond the import line itself.

In “Specifying code to be checked,” read files versus packages, focusing on what -p includes. In “Missing imports,” read the import warning. Then go to “Missing library stubs or py.typed marker” and read the first remedies: obtaining library-provided hints, installing compatible stubs, or supplying a focused stub. Finally, skim the first troubleshooting steps under “Cannot find implementation or library stub,” ending before “How imports are found.”

That last distinction matters in a CI pipeline. If mypy cannot find your project package or an installed dependency, first check the environment, the import spelling, and how the package was installed. A global ignore_missing_imports = true can make the pipeline green while turning imported values into Any.


Correct the program, not the diagnostic

Consider a small internal incident model. An unresolved incident has no resolution duration yet:

from dataclasses import dataclass


@dataclass(frozen=True)
class Incident:
    resolution_minutes: int | None


def exceeded_slo(incident: Incident, limit_minutes: int) -> bool:
    return incident.resolution_minutes > limit_minutes


def main() -> None:
    limit_minutes = input("SLO minutes: ")
    incident = Incident(resolution_minutes=75)
    print(exceeded_slo(incident, limit_minutes))


if __name__ == "__main__":
    main()

Mypy has two good reasons to object:

  • resolution_minutes might be None, which cannot be compared with an integer.
  • input() returns str, even if the operator types digits. exceeded_slo() requires an int.

The supplied diagnostic image illustrates that second problem in another form: a checker traces a string from input() to a value declared as int | None.

A static type-checker diagnostic highlights a `str` assigned to an incident-like record’s `age` field, which is declared `int | None`. The displayed string comes from `input()`; typing digits does not change its Python type.

Neither warning should be suppressed. The first asks for an explicit decision about unresolved incidents. The second asks for conversion and validation at the input boundary. Here is one defensible policy: evaluating a resolution SLO for an unresolved incident is invalid, and a non-integer limit is rejected.

from dataclasses import dataclass


@dataclass(frozen=True)
class Incident:
    resolution_minutes: int | None


def exceeded_slo(incident: Incident, limit_minutes: int) -> bool:
    if incident.resolution_minutes is None:
        raise ValueError("Cannot evaluate a resolution SLO for an unresolved incident")
    return incident.resolution_minutes > limit_minutes


def main() -> None:
    raw_limit = input("SLO minutes: ")
    try:
        limit_minutes = int(raw_limit)
    except ValueError as exc:
        raise ValueError("SLO minutes must be an integer") from exc

    incident = Incident(resolution_minutes=75)
    print(exceeded_slo(incident, limit_minutes))


if __name__ == "__main__":
    main()

After the None check, mypy can narrow incident.resolution_minutes to int. After int(raw_limit) succeeds, limit_minutes is an integer. The conversion can fail at runtime, which is why the boundary handles ValueError; static checking does not replace runtime validation.

Another domain policy might return False for an unresolved incident. The checker cannot choose between those policies. Its contribution is to force the case into view so that you choose deliberately. Assigning a made-up duration of zero merely to satisfy mypy would change the meaning of the data.


Investigate surprising errors without hiding them

When a diagnostic seems wrong, first ask what type mypy believes an expression has. A temporary reveal_type() call prints its inferred type during a mypy run:

raw_limit = input("SLO minutes: ")
reveal_type(raw_limit)  # mypy reports str

limit_minutes = int(raw_limit)
reveal_type(limit_minutes)  # mypy reports int

Watch a brief demonstration:

debugging mypy types (beginner - intermediate) anthony explains #299

In “debugging mypy types (beginner - intermediate),” anthonywritescode demonstrates reveal_type() as a way to inspect the checker’s reasoning instead of guessing at an annotation.

Watch type revelation for where the reported type appears during a mypy run. Treat an unimported reveal_type() call as a temporary debugging aid: remove it before running or committing application code.

When resolving a finding, work through the cause in this order:

  1. Is the runtime value wrong for the intended contract? Convert and validate it, as with input().
  2. Can the declared type represent a real state you have not handled? Narrow it with is None or isinstance, then handle that state meaningfully.
  3. Is the annotation itself inaccurate? Correct the annotation only if the broader type truly is the function’s contract; do not widen it to conceal a bug.
  4. Did an imported value become Any? Fix package discovery or look for usable library type information before considering a narrow exception.

Avoid cast() as a substitute for validation. For example, casting the string returned by input() to int changes mypy’s belief, not the string at runtime. Likewise, a broad # type: ignore can silence a legitimate None case. If a library limitation eventually requires an exception, keep it local, name the specific mypy error code, and document why the operation is safe despite what the checker can establish.


Implementation checkpoint and key takeaways

Apply the workflow to your incident package:

  1. Add mypy as a locked development dependency and configure its supported Python version in pyproject.toml.
  2. Run uv run mypy -p incident_api.
  3. For each finding, identify the declared type, the inferred type, and the runtime behavior you intend. Fix the code or an inaccurate annotation; do not begin with a suppression.
  4. Rerun mypy, then run ruff check . and ruff format --check .. Add the non-modifying mypy command to the same CI quality stage.

A type checker is most useful when its coverage remains visible: annotated functions, discoverable imports, and few unexplained Any values. The aim is not silence at any cost, but early, actionable feedback about real mismatches. With formatting, linting, and type checking in place, the next module turns to pytest: you will verify incident behavior with executable tests, starting with a focused unit test.

Can't find a good explanation? Sign up and we'll make it for you

Sign up