Python: Zero to Hero
Home/Data and Files
Share

Chapter 26: Type Hints and the typing Module

Python is dynamically typed — variables don't have fixed types, and Python doesn't check types at runtime. But large codebases written without type information are hard to maintain. You can't tell what a function expects just by reading its signature, editors can't autocomplete reliably, and bugs slip through that a type checker would have caught instantly.

Type hints fix this. They don't change how your program runs, but they tell humans and tools what types are expected. Your editor catches mistakes before you run the code. Your teammates understand your API without reading the implementation. Refactoring becomes safe.

This chapter teaches you how to read and write type hints, how to use the typing module, and how to run mypy to check your code statically.

The Basics

Type hints use the : syntax for variables and parameters, and -> for return types:

# Variable annotations
name: str = "Alice"
age:  int = 30
score: float = 95.5
active: bool = True

# Without a value — just declaring the type
user_id: int
# Function annotations
def greet(name: str) -> str:
    return f"Hello, {name}!"

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

def save_user(name: str, age: int) -> None:
    print(f"Saving {name}, age {age}")

Type hints are stored in __annotations__ but Python does not enforce them at runtime:

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

print(double(5))       # 10  — works
print(double("ha"))    # 'haha' — Python doesn't complain!
print(double.__annotations__)
# {'n': <class 'int'>, 'return': <class 'int'>}

Runtime enforcement requires a type checker like mypy or a library like pydantic. The hints themselves are documentation — very useful documentation.

Built-in Collection Types (Python 3.9+)

From Python 3.9 onwards, you can use built-in types directly as generic types:

# Python 3.9+
def process(items: list[int]) -> list[str]:
    return [str(i) for i in items]

def merge(a: dict[str, int], b: dict[str, int]) -> dict[str, int]:
    return {**a, **b}

def first_two(items: tuple[int, int]) -> int:
    return items[0] + items[1]

def unique(items: list[str]) -> set[str]:
    return set(items)

Before Python 3.9, you imported these from typing:

# Python 3.7 / 3.8 — use typing module
from typing import List, Dict, Tuple, Set

def process(items: List[int]) -> List[str]:
    return [str(i) for i in items]

From Python 3.10+, you can use X | Y instead of Union[X, Y]:

# Python 3.10+
def parse(value: str | int | None) -> float:
    if value is None:
        return 0.0
    return float(value)

Optional — Values That Can Be None

Optional[X] means the value is either X or None. It's very common for function parameters with defaults and return values that might fail:

from typing import Optional

def find_user(user_id: int) -> Optional[dict]:
    users = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
    return users.get(user_id)   # returns dict or None


def greet_user(name: Optional[str] = None) -> str:
    if name is None:
        return "Hello, stranger!"
    return f"Hello, {name}!"


user = find_user(1)
if user is not None:
    print(user["name"])   # Alice

print(greet_user())          # Hello, stranger!
print(greet_user("Alice"))   # Hello, Alice!

In Python 3.10+, Optional[X] is equivalent to X | None:

def find_user(user_id: int) -> dict | None:   # cleaner
    ...

Union — Multiple Possible Types

Union[X, Y] means the value can be either X or Y:

from typing import Union

def stringify(value: Union[int, float, bool]) -> str:
    return str(value)

# Python 3.10+ shorthand
def stringify(value: int | float | bool) -> str:
    return str(value)

Any — Opt Out of Type Checking

Any is compatible with every type. Use it when you genuinely can't or don't want to specify a type — but use it sparingly. Every Any is a gap in your type coverage.

from typing import Any

def log(value: Any) -> None:
    print(repr(value))

def passthrough(data: Any) -> Any:
    return data

Callable — Typing Functions

Callable[[ArgTypes], ReturnType] describes a callable (function, lambda, class with __call__):

from typing import Callable

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

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

# A callable with no arguments
def run(callback: Callable[[], None]) -> None:
    callback()

# Callable with any arguments (use ... as parameter list)
def call_anything(func: Callable[..., int]) -> int:
    return func()

Sequence, Iterable, Mapping — Accept More Types

Being specific about list means your function only works with lists. Using abstract types makes functions more flexible:

from typing import Sequence, Iterable, Mapping, MutableMapping

# Only works with lists
def sum_list(items: list[int]) -> int:
    return sum(items)

# Works with lists, tuples, strings, ranges — anything iterable
def sum_any(items: Iterable[int]) -> int:
    return sum(items)

# Works with lists, tuples — anything with len() and indexing
def first(items: Sequence[int]) -> int:
    return items[0]

# Read-only dict-like access
def lookup(data: Mapping[str, int], key: str) -> int:
    return data.get(key, 0)

# Writable dict-like access
def update(data: MutableMapping[str, int], key: str, value: int) -> None:
    data[key] = value

The hierarchy to remember: IterableSequencelist/tuple. Accept the most general type that meets your needs; return the most specific type you produce.

TypeVar — Generic Functions

A TypeVar is a placeholder for "some type, I don't know which one yet." It lets you write functions that work on any type while still being type-safe:

from typing import TypeVar

T = TypeVar("T")

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

def last(items: list[T]) -> T:
    return items[-1]

def identity(value: T) -> T:
    return value

The type checker knows that if you call first([1, 2, 3]), the return type is int. If you call first(["a", "b"]), the return type is str. The T is inferred from what you pass in.

You can constrain a TypeVar to specific types:

from typing import TypeVar

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

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

print(add(1, 2))       # 3     (int)
print(add(1.5, 2.5))   # 4.0   (float)
# add("a", "b")  — mypy would flag this

Generic Classes

You can make your own generic classes using Generic[T]:

from typing import TypeVar, Generic, Optional

T = TypeVar("T")

class Stack(Generic[T]):
    """A typed stack that holds items of type T."""
    def __init__(self) -> None:
        self._items: list[T] = []

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

    def pop(self) -> T:
        if not self._items:
            raise IndexError("Stack is empty.")
        return self._items.pop()

    def peek(self) -> Optional[T]:
        return self._items[-1] if self._items else None

    def __len__(self) -> int:
        return len(self._items)

    def __bool__(self) -> bool:
        return bool(self._items)

    def __repr__(self) -> str:
        return f"Stack({self._items!r})"


int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
int_stack.push(3)
print(int_stack.peek())   # 3
print(int_stack.pop())    # 3
print(len(int_stack))     # 2

str_stack: Stack[str] = Stack()
str_stack.push("hello")
str_stack.push("world")
print(str_stack.pop())    # world

Protocol — Structural Subtyping (Duck Typing, but Typed)

Python uses duck typing — if it has the right methods, it works. Protocol brings this into the type system. A class satisfies a Protocol if it has the required methods, without explicitly inheriting from it:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str:
        ...


class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius

    def draw(self) -> str:
        return f"○ Circle(r={self.radius})"


class Rectangle:
    def __init__(self, w: float, h: float) -> None:
        self.w, self.h = w, h

    def draw(self) -> str:
        return f"□ Rectangle({self.w}x{self.h})"


class Triangle:
    def draw(self) -> str:
        return "△ Triangle"


def render_all(shapes: list[Drawable]) -> None:
    for shape in shapes:
        print(shape.draw())


shapes = [Circle(5), Rectangle(3, 4), Triangle()]
render_all(shapes)

# Check at runtime (because of @runtime_checkable)
print(isinstance(Circle(1), Drawable))    # True
print(isinstance("not a shape", Drawable))  # False

Circle, Rectangle, and Triangle never inherit from Drawable. They just happen to have a draw() method. Protocol captures that relationship without forcing inheritance.

A richer protocol example:

from typing import Protocol

class Comparable(Protocol):
    def __lt__(self, other: "Comparable") -> bool: ...
    def __eq__(self, other: object) -> bool: ...

class Saveable(Protocol):
    def save(self) -> None: ...
    def load(self, path: str) -> None: ...

class Sizeable(Protocol):
    def __len__(self) -> int: ...

NamedTuple — Typed Tuples

NamedTuple gives you a tuple with named fields and type annotations — immutable, lightweight, and inspectable:

from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float
    label: str = ""   # default value


class Employee(NamedTuple):
    name:   str
    dept:   str
    salary: float
    active: bool = True


p = Point(3.0, 4.0, "origin")
print(p)         # Point(x=3.0, y=4.0, label='origin')
print(p.x)       # 3.0
print(p[1])      # 4.0  — still a tuple
print(p._asdict())   # {'x': 3.0, 'y': 4.0, 'label': 'origin'}

# Can unpack like a regular tuple
x, y, label = p

emp = Employee("Alice", "Eng", 95_000)
print(emp)       # Employee(name='Alice', dept='Eng', salary=95000, active=True)

# Works with sorted, min, max
employees = [
    Employee("Bob",    "HR",    68_000),
    Employee("Alice",  "Eng",   95_000),
    Employee("Carlos", "Sales", 74_000),
]
top = max(employees, key=lambda e: e.salary)
print(top.name)   # Alice

Use NamedTuple when you want a simple, immutable data carrier with field names. Use @dataclass when you need mutability, inheritance, or custom methods.

TypedDict — Typed Dictionaries

TypedDict describes a dictionary with specific string keys and typed values — useful for JSON-like data:

from typing import TypedDict, Required, NotRequired

class Address(TypedDict):
    street: str
    city:   str
    state:  str
    zip:    str

class User(TypedDict):
    id:      int
    name:    str
    email:   str
    address: Address

# Optional keys (Python 3.11+)
class Config(TypedDict, total=False):
    debug:   bool      # optional
    timeout: int       # optional
    host:    str       # optional

# Mix required and optional (Python 3.11+)
class ServerConfig(TypedDict):
    host:    Required[str]
    port:    Required[int]
    debug:   NotRequired[bool]
    timeout: NotRequired[float]


def send_welcome(user: User) -> str:
    return f"Welcome, {user['name']}! Your ID is {user['id']}."


alice: User = {
    "id": 1,
    "name": "Alice",
    "email": "alice@example.com",
    "address": {
        "street": "123 Main St",
        "city": "Springfield",
        "state": "IL",
        "zip": "62701",
    },
}

print(send_welcome(alice))

TypedDict is a type-checking construct only — at runtime, it's just a plain dict. mypy uses the annotation to catch key typos and wrong value types.

Literal Types

Literal restricts a value to specific constants:

from typing import Literal

Mode = Literal["r", "w", "a", "r+", "w+"]

def open_file(path: str, mode: Mode = "r") -> None:
    with open(path, mode) as f:
        pass

open_file("data.txt", "r")    # OK
open_file("data.txt", "x")    # mypy flags this — "x" not in Literal
Direction = Literal["north", "south", "east", "west"]
Status    = Literal["pending", "active", "closed"]
LogLevel  = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]

def move(direction: Direction, steps: int) -> None:
    print(f"Moving {direction} {steps} step(s)")

move("north", 3)      # OK
move("up",    1)      # mypy: Argument 1 to "move" has incompatible type "Literal['up']"

Final — Constants

Final marks a variable as constant — it cannot be reassigned:

from typing import Final

MAX_RETRIES: Final = 3
DEFAULT_HOST: Final[str] = "localhost"
PI: Final[float] = 3.14159265358979

MAX_RETRIES = 5   # mypy: Cannot assign to final name "MAX_RETRIES"

Running mypy

Install mypy and run it on your code:

pip install mypy
mypy my_script.py

A simple example to show what mypy catches:

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

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

result = greet(42)        # mypy: Argument 1 to "greet" has incompatible type "int"; expected "str"
total  = add("a", "b")   # mypy: Argument 1 to "add" has incompatible type "str"; expected "int"
$ mypy buggy.py
buggy.py:7: error: Argument 1 to "greet" has incompatible type "int"; expected "str"  [arg-type]
buggy.py:8: error: Argument 1 to "add" has incompatible type "str"; expected "int"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

Common mypy configuration in mypy.ini or pyproject.toml:

# mypy.ini
[mypy]
python_version = 3.12
strict = True
ignore_missing_imports = True

strict enables the strictest checks: no implicit Any, no untyped function definitions, no unannotated variables. Good for new projects.

Type Aliases

Give complex types a readable name:

from typing import TypeAlias

# Simple alias
JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None

# Function type
Predicate: TypeAlias = Callable[[int], bool]
Transformer: TypeAlias = Callable[[str], str]

# Nested types
Row: TypeAlias = dict[str, str | int | float | None]
Table: TypeAlias = list[Row]

def filter_table(table: Table, predicate: Callable[[Row], bool]) -> Table:
    return [row for row in table if predicate(row)]

In Python 3.12, the type statement makes aliases cleaner:

# Python 3.12+
type JSON = dict[str, JSON] | list[JSON] | str | int | float | bool | None
type Row  = dict[str, str | int | float | None]

Project: A Typed Data Pipeline

Let's build a small typed data pipeline that reads a CSV, validates the data, transforms it, and produces a report — fully annotated.

"""
pipeline.py — A typed data processing pipeline.
"""
from __future__ import annotations   # allow forward references in annotations

import csv
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Iterator, TypeVar, NamedTuple

T = TypeVar("T")


# ── Data models ───────────────────────────────────────────────────────────────

class RawRecord(NamedTuple):
    name:   str
    dept:   str
    salary: str   # raw string from CSV
    active: str   # raw string from CSV


@dataclass(frozen=True)
class Employee:
    name:   str
    dept:   str
    salary: float
    active: bool

    @classmethod
    def from_raw(cls, raw: RawRecord) -> Employee:
        return cls(
            name   = raw.name.strip(),
            dept   = raw.dept.strip(),
            salary = float(raw.salary),
            active = raw.active.strip().lower() in ("true", "1", "yes"),
        )


@dataclass
class Report:
    dept:       str
    headcount:  int
    avg_salary: float
    max_salary: float
    min_salary: float


# ── Pipeline stages ───────────────────────────────────────────────────────────

def read_csv(path: Path) -> Iterator[RawRecord]:
    with open(path, newline="") as f:
        reader = csv.DictReader(f)
        for row in reader:
            yield RawRecord(
                name   = row["name"],
                dept   = row["dept"],
                salary = row["salary"],
                active = row.get("active", "true"),
            )


def parse_employees(records: Iterator[RawRecord]) -> Iterator[Employee]:
    for record in records:
        try:
            yield Employee.from_raw(record)
        except (ValueError, KeyError) as e:
            print(f"  Skipping invalid record {record.name!r}: {e}")


def filter_employees(
    employees: Iterator[Employee],
    predicate: Callable[[Employee], bool],
) -> Iterator[Employee]:
    return (e for e in employees if predicate(e))


def group_by_dept(employees: Iterator[Employee]) -> dict[str, list[Employee]]:
    groups: dict[str, list[Employee]] = {}
    for emp in employees:
        groups.setdefault(emp.dept, []).append(emp)
    return groups


def make_report(dept: str, members: list[Employee]) -> Report:
    salaries = [e.salary for e in members]
    return Report(
        dept       = dept,
        headcount  = len(members),
        avg_salary = sum(salaries) / len(salaries),
        max_salary = max(salaries),
        min_salary = min(salaries),
    )


def print_report(reports: list[Report]) -> None:
    print(f"\n{'Department':<12} {'Count':>6} {'Avg Salary':>12} {'Min':>10} {'Max':>10}")
    print("-" * 55)
    for r in sorted(reports, key=lambda r: r.dept):
        print(
            f"{r.dept:<12} {r.headcount:>6} "
            f"${r.avg_salary:>10,.0f} "
            f"${r.min_salary:>8,.0f} "
            f"${r.max_salary:>8,.0f}"
        )


# ── Main ──────────────────────────────────────────────────────────────────────

def run(path: Path, active_only: bool = True) -> list[Report]:
    raw       = read_csv(path)
    employees = parse_employees(raw)

    if active_only:
        employees = filter_employees(employees, lambda e: e.active)

    groups  = group_by_dept(employees)
    reports = [make_report(dept, members) for dept, members in groups.items()]
    print_report(reports)
    return reports


if __name__ == "__main__":
    run(Path("employees.csv"))

Every function has annotated parameters and return types. The type checker knows that read_csv yields RawRecord, parse_employees yields Employee, group_by_dept returns dict[str, list[Employee]], and make_report returns a Report. If you wire them up wrong, mypy tells you before you run the code.

Type Hint Cheat Sheet

Syntax Meaning
x: int x is an integer
x: str | None x is a string or None
x: list[int] list of integers
x: dict[str, int] dict mapping str keys to int values
x: tuple[int, str] 2-tuple: int then str
x: tuple[int, ...] tuple of any number of ints
x: set[str] set of strings
x: frozenset[int] frozenset of integers
Optional[X] X | None
Union[X, Y] X | Y
Any any type — opt out of checking
Callable[[int, str], bool] function taking int and str, returning bool
Callable[..., int] function with any args, returning int
Iterable[X] anything you can loop over
Sequence[X] anything with len and indexing
Mapping[K, V] read-only dict-like
TypeVar("T") generic type placeholder
Generic[T] base for generic classes
Protocol structural subtyping (duck typing)
NamedTuple typed named tuple
TypedDict typed dictionary
Literal["a", "b"] only these specific values
Final cannot be reassigned

What You Learned in This Chapter

  • Type hints don't change runtime behavior — they're documentation for humans and type checkers.
  • Use -> for return types and : for parameter and variable types.
  • Python 3.9+ supports list[int], dict[str, int] directly; earlier versions use typing.List, typing.Dict.
  • Python 3.10+ supports X | Y instead of Union[X, Y].
  • Optional[X] means X | None — very common for nullable values.
  • Callable, Iterable, Sequence, Mapping describe abstract interfaces, not concrete types.
  • TypeVar enables generic functions that preserve type information.
  • Generic[T] creates generic classes.
  • Protocol describes duck-typing contracts without inheritance.
  • NamedTuple gives typed, immutable named tuples.
  • TypedDict gives typed dictionaries for JSON-like data.
  • Literal restricts values to specific constants; Final prevents reassignment.
  • Run mypy to catch type errors before they become runtime bugs.

What's Next?

Chapter 27 covers Concurrencythreading, multiprocessing, and asyncio. Python's Global Interpreter Lock (GIL) limits threads for CPU work, but they shine for I/O. asyncio lets a single thread handle thousands of concurrent I/O operations. You'll learn when to use each and how to write both threaded and async code correctly.

© 2026 Abhilash Sahoo. Python: Zero to Hero.