跪拜 Guibai
← Back to the summary

How Type Hints and mypy Bring Compile-Time Checks to Python

Python Type Hints and mypy: Improving Code Maintainability

1. Introduction: Why Type Hints?

Python is a dynamically typed language; variable types are determined at runtime. This flexibility boosts development speed but also plants landmines: wrong argument types passed to functions, return values misused, callers not updated after refactoring. These problems often surface only at runtime or even in production.

Type Hints, formally introduced in PEP 484, let developers declare types for variables, parameters, and return values directly in source code. The annotations themselves don't affect runtime behavior, but combined with the static checker mypy, they surface a large class of type errors during the coding phase.

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

greet(42)  # mypy reports: Argument 1 has incompatible type "int"; expected "str"

The benefits of type hints fall into three buckets:

The diagram below shows where type hints fit into the development workflow:

flowchart LR
  A[Write code with type hints] --> B[IDE real-time hints: Pylance or PyCharm]
  A --> C[mypy static check]
  C --> D{Types match?}
  D -->|Yes| E[Merge to main]
  D -->|No| F[Report error with line number]
  F --> A
  B --> E

2. Basic Type Annotations

2.1 Variable Annotations

Python 3.6 introduced variable annotation syntax:

name: str = "Alice"
age: int = 30
height: float = 1.68
is_active: bool = True

Annotations can also be declared without assignment, leaving the variable in a "declared but undefined" state; using it directly raises NameError:

count: int
print(count)  # NameError: name 'count' is not defined

2.2 Function Parameters and Return Values

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

def log(message: str) -> None:
    print(message)

-> None means the function returns no meaningful value. Note the difference from omitting a return annotation: with no annotation, mypy won't check the return type.

2.3 Common Built-in Types

Annotation Meaning Example
int Integer x: int = 1
float Floating-point y: float = 1.0
str String s: str = "a"
bool Boolean b: bool = True
bytes Byte string data: bytes = b"abc"
None Null value n: None = None
Any Any type (disables checking) v: Any = object()

Any is a double-edged sword: it quickly silences errors, but it also disables type checking at that spot. Use it sparingly.

3. Container Types: List, Dict, Optional, Union

3.1 Generic Containers

Before Python 3.9, you needed to import capitalized container types from typing; from 3.9 onward, built-in types can be used directly as generics:

# Python 3.9+
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 95, "Bob": 88}
point: tuple[int, int] = (1, 2)
tags: set[str] = {"python", "typing"}

# Python 3.8 and earlier
from typing import List, Dict, Tuple, Set

names: List[str] = ["Alice", "Bob"]
scores: Dict[str, int] = {"Alice": 95}

Use lowercase built-in generics in new projects; for older projects, stay consistent with the existing style.

3.2 Optional: Possibly None

Optional[T] is equivalent to T | None; the value is either T or None:

from typing import Optional

def find_user(user_id: int) -> Optional[str]:
    if user_id == 1:
        return "Alice"
    return None

# Python 3.10+ syntax
def find_user(user_id: int) -> str | None:
    ...

With Optional, mypy forces you to check for None before accessing attributes:

user = find_user(2)
print(user.upper())  # mypy error: Item "None" of "Optional[str]" has no attribute "upper"

if user is not None:
    print(user.upper())  # passes

3.3 Union: One of Several Types

Union[A, B] means the value can be either A or B:

from typing import Union

def parse(value: Union[int, str]) -> int:
    if isinstance(value, str):
        return int(value)
    return value

# Python 3.10+ syntax
def parse(value: int | str) -> int:
    ...

Relation between Union and Optional: Optional[T] is shorthand for Union[T, None].

3.4 Type Narrowing

mypy automatically narrows types based on isinstance, is None, in, and similar checks:

def process(value: int | str | list[int]) -> int:
    if isinstance(value, int):
        return value          # here value is int
    if isinstance(value, str):
        return len(value)     # here value is str
    return sum(value)         # here value is list[int]

The narrowing flow can be summarized as:

flowchart TD
  A[value is int or str or list] --> B{isinstance int?}
  B -->|Yes| C[value narrowed to int]
  B -->|No| D{isinstance str?}
  D -->|Yes| E[value narrowed to str]
  D -->|No| F[value narrowed to list]
  C --> G[return value]
  E --> H[return len]
  F --> I[return sum]

The diagram below shows the containment relationships for containers and union types from this section:

flowchart TD
  A[Type annotations] --> B[Container generics]
  A --> C[Union types]
  B --> B1[list]
  B --> B2[dict]
  B --> B3[tuple]
  B --> B4[set]
  C --> C1[Optional equivalent to T or None]
  C --> C2[Union equivalent to A or B]
  C1 --> D[Requires None check before use]
  C2 --> E[Narrow with isinstance]

4. Function and Class Annotations

4.1 Default Parameters and Keyword Arguments

def create_user(
    name: str,
    age: int = 0,
    tags: list[str] | None = None,
) -> dict[str, object]:
    if tags is None:
        tags = []
    return {"name": name, "age": age, "tags": tags}

Be careful with the mutable-default trap: don't write tags: list[str] = []; use None as the default and initialize inside the function body.

4.2 Callable Objects

Functions themselves can be annotated using Callable:

from typing import Callable

def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    return func(a, b)

apply(lambda x, y: x + y, 1, 2)

In Callable[[param_types...], return_type], parameters are wrapped in square brackets and the return type comes last.

4.3 Class Attributes and Methods

class User:
    name: str
    age: int

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

    def greet(self) -> str:
        return f"Hi, I'm {self.name}"

    @classmethod
    def from_dict(cls, data: dict[str, object]) -> "User":
        return cls(str(data["name"]), int(data["age"]))

Class attributes are declared in the class body; instance attributes are assigned in __init__. To return the class's own type, use Self in Python 3.11+ or a string forward reference "User" in older versions.

4.4 Dataclasses

dataclass pairs naturally with type annotations:

from dataclasses import dataclass, field

@dataclass
class Order:
    order_id: int
    items: list[str] = field(default_factory=list)
    remark: str | None = None

field(default_factory=list) solves the mutable default problem, and mypy correctly infers field types.

5. Generics Basics

5.1 Why Generics

Suppose you want a function that returns the first element:

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

Returning object loses type information, leaving callers to manually assert. Generics preserve type information through the call chain:

from typing import TypeVar

T = TypeVar("T")

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

n = first([1, 2, 3])        # n is inferred as int
s = first(["a", "b"])       # s is inferred as str

5.2 Constrained TypeVar

from typing import TypeVar

Number = TypeVar("Number", int, float)

def double(x: Number) -> Number:
    return x * 2

double(1)      # OK, returns int
double(1.5)    # OK, returns float
double("a")    # mypy error

You can also use bound to set an upper bound:

from typing import TypeVar

class Animal:
    def speak(self) -> str:
        return "..."

A = TypeVar("A", bound=Animal)

def make_speak(animal: A) -> str:
    return animal.speak()

5.3 Generic Classes

from typing import Generic, TypeVar

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()

stack: Stack[int] = Stack()
stack.push(1)
stack.push("a")  # mypy error

Python 3.12 adds the more concise PEP 695 syntax:

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

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

6. mypy Checking and Team Collaboration

6.1 Installation and Basic Usage

pip install mypy
mypy your_module.py

mypy outputs all type errors with line numbers:

your_module.py:5: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)

6.2 Configuration File

Create mypy.ini or use pyproject.toml in the project root:

# pyproject.toml
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
no_implicit_optional = true
strict_equality = true

[[tool.mypy.overrides]]
module = "third_party.*"
ignore_missing_imports = true

Key options explained:

6.3 Gradual Adoption Strategy

For existing codebases, enabling strict mode all at once typically produces hundreds or thousands of errors. The recommended approach is gradual:

  1. Get it passing: Check only new code; exempt existing code with # type: ignore or ignore_errors temporarily.
  2. Work module by module: Start with core modules, enabling disallow_untyped_defs one at a time.
  3. Tighten configuration: As module coverage grows, progressively enable strict options.
  4. Add to CI: Run mypy in the pipeline to block type errors from merging into main.
flowchart LR
  A[Stage 1: check only new code] --> B[Stage 2: enable strict checks per module]
  B --> C[Stage 3: gradually enable strict options]
  C --> D[Stage 4: add CI to block errors from merging]
  D --> E[Existing code fully typed]

The overall pace of gradual adoption:

flowchart LR
  A["Stage 1<br/>Only new code"] --> B["Stage 2<br/>Per-module strict checks"]
  B --> C["Stage 3<br/>Gradually enable strict options"]
  C --> D["Stage 4<br/>Add CI to block error merges"]
  D --> E["Existing code fully typed"]

6.4 Team Collaboration Guidelines

6.5 Common Problems

Problem 1: Third-party library lacks type stubs

[[tool.mypy.overrides]]
module = "some_untyped_lib.*"
ignore_missing_imports = true

Or install community-maintained stub packages: pip install types-requests.

Problem 2: Circular imports

Use from __future__ import annotations to defer evaluation of all annotations, or use string forward references:

from __future__ import annotations

class Node:
    def __init__(self, next: Node | None = None) -> None:
        self.next = next

Problem 3: Dynamic attributes

For attributes added dynamically, use setattr with # type: ignore, or switch to more precise structural types like TypedDict or Protocol.

7. Summary

Type annotations aren't a burden; they turn "implicit contracts" into "explicit contracts." This guide moved from basic annotations to generics and mypy practice. The core takeaways:

Start by annotating new code, use mypy to guard the increment, then gradually backfill existing code. The payoff of type safety grows with codebase size.

Finally, a mind map that reviews the whole article's knowledge structure:

mindmap
  root((Python type hints and mypy))
    Basic annotations
      Variable annotations
      Function parameters and return values
      Built-in types and Any
    Containers and unions
      list, dict, tuple, set
      Optional and None checks
      Union and type narrowing
    Functions and classes
      Default parameter traps
      Callable
      Class attributes and dataclass
    Generics
      TypeVar and constraints
      Generic classes and PEP 695
    mypy practice
      Configuration and strict options
      Gradual adoption
      CI and team guidelines