Chapter Eighteen

Type Hints and Static Analysis

Learning Objectives
  1. Annotate function parameters, return types, and variables using Python's type hint syntax
  2. Apply typing module constructs including Optional, Union, Callable, TypeVar, and Protocol
  3. Run mypy against a codebase and interpret its error messages to fix type mismatches
  4. Explain the philosophy of gradual typing and justify when full annotation is worthwhile
  5. Distinguish between static type checking and runtime type checking and choose the right approach

Python is a dynamically typed language, and that is one of the reasons people love it. You do not declare x as an integer before assigning 42 to it. You just write x = 42 and move on. This flexibility makes prototyping fast and code concise. But it comes at a cost: when a function expects a string and you hand it an integer, Python will not complain until the code actually runs — and sometimes not even then, if the failure is subtle. On a small script, this is fine. On a codebase with fifty thousand lines and twenty contributors, it becomes a breeding ground for bugs that hide in rarely-executed paths and surface at the worst possible times. Type hints are Python's answer to this problem, and they manage a neat trick: they give you the safety benefits of a statically typed language without sacrificing the flexibility that makes Python enjoyable to write.

Why Type Hints Matter

Type hints serve three audiences simultaneously. For humans, they are documentation that never goes stale — a function signature like def greet(name: str) -> str tells you at a glance what goes in and what comes out, without reading the implementation. For IDEs, they power autocompletion, inline error highlighting, and refactoring tools — your editor can warn you about a type mismatch the moment you type it. And for static analysis tools like mypy, they enable whole-program checking that catches bugs before your code ever runs.

The crucial point is that type hints are completely optional. Python ignores them at runtime. They do not make your code faster or slower. They do not prevent your program from running. They are metadata — annotations that tools can read but the interpreter does not enforce. This means you can adopt them gradually, one function at a time, without rewriting your codebase.

Basic Annotations

The syntax is straightforward. You annotate function parameters with a colon and the type, and the return type with ->:

def greet(name: str) -> str:
    return f"Hello, {name}!"

def add(a: int, b: int) -> int:
    return a + b

def is_even(n: int) -> bool:
    return n % 2 == 0

For variables, you can annotate inline:

count: int = 0
name: str = "Alice"
scores: list[int] = [85, 92, 78]
lookup: dict[str, int] = {"alice": 85, "bob": 92}

Since Python 3.9, built-in types like list, dict, tuple, and set accept subscripts directly — list[int], dict[str, float]. In older versions (3.7 and 3.8), you needed the capitalised versions from the typing module: List[int], Dict[str, float]. Modern code should use the lowercase forms.

Optional and Union

When a value might be one of several types, you use Union. When it might be a value or None, you use Optional — which is just shorthand for Union[X, None]:

from typing import Optional, Union

def find_user(user_id: int) -> Optional[str]:
    """Returns the username, or None if not found."""
    users = {1: "alice", 2: "bob"}
    return users.get(user_id)

def process(value: Union[int, float]) -> float:
    return value * 2.0

Python 3.10 introduced the | syntax, which is more readable:

def find_user(user_id: int) -> str | None:
    ...

def process(value: int | float) -> float:
    ...

Use the | syntax if your minimum Python version is 3.10 or later. It is cleaner and requires no import.

The typing Module

The typing module provides a rich vocabulary for expressing complex types. Here are the constructs you will use most often:

Any opts out of type checking entirely — a value of type Any is compatible with everything. Use it sparingly, as it defeats the purpose.

Callable describes functions and other callables. Callable[[int, int], bool] means "a function that takes two ints and returns a bool":

from typing import Callable

def apply_twice(func: Callable[[int], int], value: int) -> int:
    return func(func(value))

result = apply_twice(lambda x: x + 1, 5)
# result is 7

TypeVar lets you write generic functions — functions where the types of inputs and outputs are related but not fixed:

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

# first(["a", "b"]) returns str
# first([1, 2, 3]) returns int

The type checker understands that if you pass a list[str], you get back a str. Without TypeVar, you would have to annotate the return as Any and lose that precision.

Type Hints for Classes

Type hints work naturally with classes. Annotate attributes in the class body or in __init__:

class Patient:
    name: str
    age: int
    scores: list[float]

    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age
        self.scores = []

    def add_score(self, score: float) -> None:
        self.scores.append(score)

    def average_score(self) -> float:
        return sum(self.scores) / len(self.scores)

Note that __init__ always returns None. Class methods that return an instance of the class itself can use Self (from typing, available in Python 3.11+) or a string annotation:

from typing import Self

class Vector:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def scale(self, factor: float) -> Self:
        return Vector(self.x * factor, self.y * factor)

Generics

When a class itself is parameterised by a type — like a container that holds values of a specific type — you define a generic class:

from typing import TypeVar, Generic

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def push(self, item: T) -> None:
        self._items.append(item)

    def pop(self) -> T:
        return self._items.pop()

int_stack: Stack[int] = Stack()
int_stack.push(42)
value: int = int_stack.pop()

The type checker now knows that int_stack.pop() returns an int, not Any. Generics bring the same level of type safety to your own containers that list[int] brings to built-in lists.

Protocols: Structural Subtyping

Traditional type checking in Python uses nominal subtyping — a class satisfies a type if it explicitly inherits from it. But Python's duck typing philosophy says "if it walks like a duck and quacks like a duck, it is a duck." Protocols bridge this gap by defining structural subtyping — a class satisfies a Protocol if it has the right methods, regardless of inheritance:

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:
    def draw(self) -> None:
        print("Drawing a circle")

class Square:
    def draw(self) -> None:
        print("Drawing a square")

def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())   # Fine — Circle has a draw() method
render(Square())   # Fine — Square has a draw() method

Neither Circle nor Square inherits from Drawable. But both satisfy its interface, so the type checker accepts them. Protocols let you write type-safe code that still feels like idiomatic Python, respecting duck typing while catching errors before runtime.

Running mypy

mypy is the most established static type checker for Python. Install it with pip install mypy and run it against your code:

mypy my_module.py

If there are type errors, mypy reports them with file, line number, and a clear description:

my_module.py:12: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
my_module.py:25: error: Incompatible return value type (got "Optional[str]", expected "str")

You fix the code (or fix the annotations), run mypy again, and iterate. Common flags include --strict (enforce annotations everywhere), --ignore-missing-imports (suppress errors from unannotated third-party libraries), and --show-error-codes (display error codes for targeted suppression).

For large projects, add mypy to your CI pipeline so that type errors block merging, just like failing tests.

Gradual Typing

Python's type system is explicitly designed for gradual typing — the philosophy that you do not have to annotate everything at once. You can start with no annotations at all, add them to the most critical functions first, expand coverage over time, and leave experimental code untyped until it stabilises.

This is not a compromise; it is a feature. A type system that demanded full coverage from day one would be un-Pythonic and would drive away the very users who benefit from it most — people exploring data, prototyping ideas, writing scripts. Gradual typing meets you where you are.

A practical strategy: annotate public interfaces first (function signatures, class APIs), then internal helpers as time allows. Use # type: ignore comments sparingly and only with a comment explaining why.

pyright and Other Tools

mypy is not the only option. pyright (from Microsoft, powering Pylance in VS Code) is faster and often stricter. pytype (from Google) infers types even without annotations. pyre (from Facebook) focuses on large codebases. All read the same annotations, so your type hints are portable across tools.

The choice often comes down to your editor. If you use VS Code, pyright is already built in via Pylance. If you use PyCharm, its built-in type checker reads the same annotations. If you want standalone CI checking, mypy is the established default.

Runtime Type Checking vs Static Analysis

It is worth being clear about what type hints do not do. They do not prevent your program from running if types are wrong. They do not add runtime checks. def greet(name: str) does not stop you from calling greet(42) — Python will happily try to run it.

If you need runtime validation — checking that an API received the right types, or that user input matches a schema — use libraries like pydantic or beartype, which read type annotations and enforce them at runtime:

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

user = User(name="Alice", age=30)     # Fine
user = User(name="Alice", age="thirty")  # Raises ValidationError

Static analysis catches bugs during development. Runtime validation catches bad data during execution. They complement each other rather than competing.

Type hints are one of the most significant additions to Python in the last decade. They do not change what Python is — dynamic, flexible, forgiving — but they add a safety net that scales with the size and importance of your code. Start small, annotate the boundaries, let your tools do the work, and gradually build a codebase where bugs have fewer places to hide.