Phase 5 · Advanced PythonModule 26~34 min read

Type Hints & Static Typing

Add optional type annotations and catch bugs early with mypy.

What you'll learn

Type hints let you annotate what your variables, parameters, and return values are supposed to be. Python doesn't enforce them at runtime — but your editor and a tool called mypy use them to catch whole categories of bugs before you ever run the code, and to give you sharper autocomplete and documentation.

By the end of this lesson you'll be able to:

  • Annotate variables, function parameters, and return types
  • Describe collections and "maybe-missing" values with list[int] and X | None
  • Write reusable generic code with TypeVar and structural types with Protocol
  • Model exact shapes and values with TypedDict, Literal, and Final
  • Run mypy to find type bugs statically

Why type hints

A type hint is a promise about a value, written right in the code. The crucial thing to understand first: CPython does not check them at runtime. They're stored on the object for tools to read, then otherwise ignored.

hints.py
def double(n: int) -> int:
    return n * 2

print(double(10))        # 20   — works as intended
print(double("ab"))      # abab — Python IGNORES the hint at runtime!

# The hints are stored for tools to read, not enforced:
print(double.__annotations__)

Key idea

Hints don't change how your program runs — double("ab") still returns "abab". Their value is that a separate tool (mypy, or your editor) reads them and flags mismatches for you. This is called gradual typing: add hints where they help, skip them where they don't.

Variables, parameters & returns

The syntax is name: type for variables and parameters, and -> type after the parentheses for what a function returns. A function that returns nothing is annotated -> None.

basics.py
# Variable annotations
count: int = 0
name: str = "Ada"
ratio: float = 0.75
active: bool = True

# Parameters and the return type (after ->)
def area(width: float, height: float) -> float:
    return width * height

# A function that returns nothing is annotated -> None
def log(message: str) -> None:
    print(f"[LOG] {message}")

print(area(3, 4))
log("started")

Tip

You rarely need to annotate local variables — tools infer them from the value. Focus your hints on function signatures (parameters and return types), which is where they pay off most for readers and callers.

Collections, Optional & Union

Say what's inside a container with the built-in generics: list[int], dict[str, float], tuple[int, int]. When a value might be absent, or could be one of several types, combine types with the | operator.

collections.py
# Built-in generics (Python 3.9+): say what's INSIDE the container
scores: list[int] = [98, 85, 91]
prices: dict[str, float] = {"pen": 1.5, "book": 9.0}
point: tuple[int, int] = (3, 4)

# "might be missing" -> use  X | None   (Python 3.10+)
def find_user(uid: int) -> str | None:
    users = {1: "Ada", 2: "Linus"}
    return users.get(uid)          # returns str OR None

# "one of several types" -> use  X | Y
def to_int(x: int | str) -> int:
    return int(x)

print(find_user(1))        # Ada
print(find_user(99))       # None
print(to_int("42") + 1)    # 43

Older syntax you'll still see

Before Python 3.10 you wrote Optional[str] instead of str | None, Union[int, str] instead of int | str, and imported List/Dict from typing. The modern | and lowercase built-ins are preferred in new code, but the old forms mean exactly the same thing.

Generics, TypeVar & Protocol

A generic function works with many types while preserving the relationship between them. A TypeVar is a placeholder for "whatever type is passed in" — so first below returns the same type it received, and tools know it:

generic.py
from typing import TypeVar

T = TypeVar("T")                     # "some type, chosen per call"

def first(items: list[T]) -> T:      # returns the SAME type it received
    return items[0]

n = first([10, 20, 30])              # tools infer n is an int
s = first(["a", "b", "c"])           # and s is a str
print(n, s)

A Protocol describes a type by the methods it has, not the class it inherits from — static duck typing. Any object with a matching area() method satisfies SupportsArea, without ever importing or subclassing it:

protocol.py
from typing import Protocol

class SupportsArea(Protocol):
    def area(self) -> float: ...     # any object with area() qualifies

class Circle:
    def __init__(self, r: float):
        self.r = r
    def area(self) -> float:
        return 3.14159 * self.r ** 2

def describe(shape: SupportsArea) -> str:
    return f"area = {shape.area():.2f}"

print(describe(Circle(2)))           # Circle never mentions SupportsArea

TypedDict, Literal & Final

Three more tools cover common real-world shapes: TypedDict for a dict with known keys, Literal for a value restricted to an exact set, and Final for a constant that must not be reassigned.

special.py
from typing import TypedDict, Literal, Final

class User(TypedDict):               # a dict with a known shape
    name: str
    age: int

ada: User = {"name": "Ada", "age": 36}

# Literal restricts a value to an exact set of choices
def move(direction: Literal["left", "right"]) -> None:
    print("moving", direction)

# Final marks a constant that should never be reassigned
MAX_RETRIES: Final = 3

move("left")
print(ada["name"], "->", MAX_RETRIES)

Tip

Literal is fantastic for "stringly-typed" APIs: mypy will reject move("up") because "up" isn't one of the allowed choices — turning a whole class of typo bugs into instant errors.

Catching bugs with mypy

Hints only earn their keep when a tool checks them. mypy is the standard static type checker: it reads your annotations and reports mismatches without running the program. Here's code with a real bug:

account.py
# account.py
def withdraw(balance: int, amount: int) -> int:
    return balance - amount

new_balance = withdraw(100, "20")    # "20" is a str, not an int
print(new_balance)

Run mypy and it points straight at the problem — line, argument, and expected type:

terminal
$ pip install mypy
$ mypy account.py
account.py:5: error: Argument 2 to "withdraw" has incompatible type
"str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Watch out

Because the argument is a string, this would also crash at runtime with a TypeError on the subtraction — but mypy caught it at your desk, before a single line executed. On a large codebase that difference is the difference between a code-review comment and a 2 a.m. production page.

Recap & quick check

Key takeaways

  • Type hints annotate values but are NOT enforced at runtime — a separate tool (mypy or your editor) reads them.
  • Syntax: name: type for variables and params, -> type for returns; use -> None for no return value.
  • Describe containers with list[int], dict[str, float]; combine types with X | None and X | Y (3.10+).
  • TypeVar makes functions/classes generic; Protocol matches types by their methods (structural typing).
  • TypedDict types a dict's shape, Literal restricts to exact values, Final marks constants.
  • Run mypy to catch type bugs statically, before the program ever runs.

Quick check

1. What happens to type hints when your program runs in CPython?

2. How do you annotate a function that takes an int and returns nothing?

3. Which modern hint means 'a string, or possibly None'?

4. What is a Protocol used for?

5. What does running mypy on your code do?

With types under your belt, let's tackle another precision tool for working with text. Next up: Module 27 — Regular Expressions.