EthicsComputerCourses
Join training +91 90532 10052

Type Checking with mypy

Python · Lesson 60 of 68 Advanced 8 min read Updated

In Type Hints you learned to write hints such as list[int] and str | None. Hints on their own change nothing: Python ignores them when it runs. Their value appears when a type checker reads your code without running it and points out lines that will crash or misbehave. Many companies run a type checker on every change, next to the tests, and treat its errors like failing tests.

This lesson uses mypy, the original Python type checker, but the ideas work the same in pyright (which powers VS Code's Python support) and ty, a newer checker from the makers of uv and ruff. You will run mypy, fix the errors it finds, model real data more precisely with TypedDict, Literal and Final, and switch on strict mode for new code.

Running mypy

Install it as a development tool (uv add --dev mypy, or python -m pip install mypy in a virtual environment). Here is a file with three bugs. Python would only hit them when the bad line actually runs, perhaps months later, on a customer's data:

Python
def find_phone(contacts: dict[str, str], name: str) -> str | None:
    return contacts.get(name)


def sms_header(contacts: dict[str, str], name: str) -> str:
    phone = find_phone(contacts, name)
    return "To: +91 " + phone.replace(" ", "")


def total_fees(fees: list[int]) -> int:
    return sum(fees) / len(fees)


print(sms_header({"Isha": "99990 54321"}, "Isha"))
print(total_fees("1200"))
Terminal
mypy contacts.py
Output
contacts.py:7: error: Item "None" of "str | None" has no attribute "replace"  [union-attr]
contacts.py:11: error: Incompatible return value type (got "float", expected "int")  [return-value]
contacts.py:15: error: Argument 1 to "total_fees" has incompatible type "str"; expected "list[int]"  [arg-type]
Found 3 errors in 1 file (checked 1 source file)

Each error gives the file, the line, a message and an error code in square brackets. Read them as questions: "What if find_phone() returns None?" "/ always gives a float, so is int really the right return type?" "Why is a string being passed where a list is expected?" Every one is a real bug, found without running anything.

Narrowing: proving a value is safe

The first error is the most common one you will see. phone has the type str | None, so calling a string method on it is not safe. You fix it by narrowing the type: an if check after which the checker knows which type remains.

PythonRun it yourself
def find_phone(contacts: dict[str, str], name: str) -> str | None:
    return contacts.get(name)


def sms_header(contacts: dict[str, str], name: str) -> str:
    phone = find_phone(contacts, name)
    if phone is None:
        raise KeyError(f"no phone number saved for {name}")
    return "To: +91 " + phone.replace(" ", "")      # phone is a str here


def average_fee(fees: list[int]) -> float:
    return sum(fees) / len(fees)


print(sms_header({"Isha": "99990 54321"}, "Isha"))
print(average_fee([1200, 1500, 900]))
Output
To: +91 9999054321
1200.0

mypy now reports Success: no issues found. The checker understands is None, is not None, isinstance(), truthiness checks such as if phone:, early returns and raise. To see what the checker thinks a type is at some line, write reveal_type(phone); mypy prints a note such as Revealed type is "str". Remove these lines afterwards.

Precise types for real data

Data from JSON files and web APIs usually arrives as dictionaries. dict[str, Any] tells the checker nothing about the keys. A TypedDict describes the exact keys and the type of each value. Add Literal for a value that may only be one of a few fixed strings, and Final for a constant that must never be reassigned:

PythonRun it yourself
from typing import Final, Literal, NotRequired, TypedDict

GST_RATE: Final = 18

type Status = Literal["placed", "packed", "shipped", "delivered"]


class Order(TypedDict):
    id: int
    customer: str
    amount: int
    status: Status
    coupon: NotRequired[str]


def with_gst(order: Order) -> float:
    return order["amount"] * (100 + GST_RATE) / 100


raw: Order = {"id": 101, "customer": "Rohan", "amount": 1299, "status": "placed"}
print(with_gst(raw))
print(type(raw).__name__, Order.__required_keys__ == {"id", "customer", "amount", "status"})
print(raw.get("coupon", "no coupon"))
Output
1532.82
dict True
no coupon

At runtime an Order is a plain dict, so it costs nothing and works with json.loads() directly. NotRequired marks a key that may be missing. The checker, however, now catches typos and wrong values. Adding these three lines to the file:

Python
bad: Order = {"id": 102, "customer": "Ananya", "amount": 499, "status": "dispatched"}
raw["amout"] = 1500
GST_RATE = 12
Output
orders.py:24: error: Incompatible types (expression has type "Literal['dispatched']", TypedDict item "status" has type "Literal['placed', 'packed', 'shipped', 'delivered']")  [typeddict-item]
orders.py:25: error: TypedDict "Order" has no key "amout"  [typeddict-unknown-key]
orders.py:25: note: Did you mean "amount"?
orders.py:26: error: Cannot assign to final name "GST_RATE"  [misc]
Found 3 errors in 1 file (checked 1 source file)

A TypedDict only describes data; it does not check data arriving from outside at runtime. For that, validate the values yourself (see Secure Coding Basics) or use a validation library.

Making sure every case is handled

Literal types combine well with match and assert_never(). If someone later adds "netbanking" to the Payment type but forgets to handle it, the checker reports the forgotten case:

Python
from typing import Literal, assert_never

type Payment = Literal["upi", "card", "cash", "netbanking"]


def convenience_fee(method: Payment, amount: int) -> float:
    match method:
        case "upi" | "cash":
            return 0
        case "card":
            return round(amount * 0.02, 2)
        case _:
            assert_never(method)
Output
pay.py:13: error: Argument 1 to "assert_never" has incompatible type "Literal['netbanking']"; expected "Never"  [arg-type]

If the code ran anyway with "netbanking", assert_never() would raise AssertionError: Expected code to be unreachable, but got: 'netbanking' instead of quietly returning a wrong fee.

Configuring mypy and strict mode

Put the settings in pyproject.toml, so every developer and the build server use the same ones:

TOML
[tool.mypy]
python_version = "3.14"
files = ["src", "tests"]
strict = true

[[tool.mypy.overrides]]
module = ["old_reports.*"]
strict = false

Now a plain mypy command checks the listed folders. One surprise first: by default, mypy skips functions without any hints. This old function passes the default check, but --strict reports it:

Python
import json


def load_rates(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def price_with_gst(price: float, rate) -> float:
    return price * (100 + rate) / 100
Output
legacy.py:4: error: Function is missing a type annotation  [no-untyped-def]
legacy.py:9: error: Function is missing a type annotation for one or more parameters  [no-untyped-def]
legacy.py:10: error: Returning Any from function declared to return "float"  [no-any-return]
Found 3 errors in 1 file (checked 1 source file)

Strict mode turns on a group of extra checks: every function must be annotated, Any may not leak out silently, and unused # type: ignore comments are reported. Use it for new projects from day one. For a large old codebase, add types gradually: start with default settings, annotate the most important modules, then make strict mode apply to one package at a time with overrides, as above.

Escape hatches, used carefully

Sometimes you know more than the checker. Three tools exist for that, and each one hides possible bugs, so use them rarely and leave a comment:

  • # type: ignore[arg-type] at the end of a line silences one error code on that line. Always include the code in brackets.
  • typing.cast(str, value) tells the checker "treat this as a str". It does nothing at runtime.
  • Any switches checking off for a value completely.

Third-party libraries without hints are another source of Any. Many libraries ship their own hints; for others, install a stub package, such as types-requests, which contains only the type information.

Common mistakes

  • Running the checker once and never again. Add it to your pre-push checks (see Project Workflow with uv & ruff), or new errors creep back.
  • Silencing errors instead of fixing them. Most union-attr errors are real None bugs; add a check.
  • Thinking a passing check means correct code. The checker proves types fit, not that the logic is right. You still need tests.
  • Expecting TypedDict or Literal to validate JSON at runtime. They only inform the checker.
  • Forgetting that unannotated functions are skipped. Without strict mode or check_untyped_defs = true, mypy ignores their bodies.

Key points

  • A type checker reads code without running it and reports type errors with a line number and an error code.
  • Narrow X | None with is None checks, isinstance() or early returns before using the value.
  • TypedDict describes dictionary keys, Literal fixes allowed values, Final protects constants.
  • assert_never() in the last case makes the checker report unhandled Literal or enum values.
  • Configure mypy in [tool.mypy]; use strict = true for new code and add types to old code gradually.
  • Use # type: ignore[code], cast() and Any rarely, and never as a habit.

Exercise

Run mypy in your head (or for real) on this function and list every problem it would report. Then rewrite it with correct hints, a Literal type for the seat class, and a narrowing check, so that mypy in strict mode reports no errors.

Python
FARES = {"sleeper": 450, "3ac": 1150, "2ac": 1650}

def ticket_price(seat_class, passengers, discount=None):
    price = FARES.get(seat_class) * passengers
    if discount:
        price = price - price * discount / 100
    return price
Show solution

Strict mode first reports the missing annotations. Once hints are added, it reports two more bugs: FARES.get() may return None, which cannot be multiplied, and price starts as an int but is later given a float. One correct version:

PythonRun it yourself
from typing import Literal

type SeatClass = Literal["sleeper", "3ac", "2ac"]

FARES: dict[SeatClass, int] = {"sleeper": 450, "3ac": 1150, "2ac": 1650}


def ticket_price(
    seat_class: SeatClass, passengers: int, discount: float | None = None
) -> float:
    fare = FARES.get(seat_class)
    if fare is None:
        raise ValueError(f"unknown seat class: {seat_class}")
    price: float = fare * passengers
    if discount is not None:
        price -= price * discount / 100
    return price


print(ticket_price("3ac", 2))
print(ticket_price("sleeper", 4, discount=10))
Output
2300
1620.0

The Literal type lets the checker reject a call such as ticket_price("first", 1). Writing if discount is not None instead of if discount also treats a discount of 0 correctly and makes the intent clear.

Quick Quiz

1. What does mypy do with your program?

2. phone has type str | None. Which line lets the checker accept phone.upper() afterwards?

3. What is a TypedDict at runtime?

4. Why does mypy report nothing for an old file full of unannotated functions?

Learn Python with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call