Type Hints & Static Typing

Master type annotations, the typing module, generics, Protocol, and mypy for safer Python code.

Advanced 35 min read 🐍 Python

Why Type Hints?

Python is dynamically typed — you don't declare variable types. This is great for quick prototyping, but as codebases grow, it becomes hard to know what type a function expects or returns. Type hints (PEP 484, Python 3.5+) let you annotate your code with expected types without changing how Python runs it.

Type hints are optional but powerful

Python ignores type hints at runtime. They're documentation that tools like mypy, IDEs (VS Code, PyCharm), and linters use to catch bugs before you run your code. Think of them as a spell-checker for types.

Basic Annotations

def greet(name: str, times: int = 1) -> str:
    """Greet someone. Type hints on params and return."""
    return (f"Hello, {name}! " * times).strip()

# Variables can be annotated too
age: int = 30
scores: list[int] = [95, 87, 92]
config: dict[str, str] = {"host": "localhost", "port": "8080"}

# Python 3.10+ allows X | Y instead of Union
def process(value: str | int) -> str:
    return str(value)

result = greet("Alice", 2)
print(result)
Output
Hello, Alice! Hello, Alice!

The typing Module

For more complex types, use the typing module. Python 3.9+ lets you use built-in types directly (list[int]), but typing is needed for older versions and advanced patterns:

from typing import Optional, Union, Callable, Any

# Optional — can be the type or None
def find_user(user_id: int) -> Optional[dict]:
    """Returns user dict or None if not found."""
    users = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
    return users.get(user_id)

# Callable — a function type
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    """Apply a function to two arguments."""
    return func(a, b)

result = apply(lambda x, y: x + y, 3, 4)
print(result)  # 7

# Union (pre-3.10 style)
def format_value(value: Union[str, int, float]) -> str:
    return f"Value: {value}"
Output
7

Generics and TypeVar

Generics let you write functions and classes that work with any type while maintaining type safety. TypeVar creates a type variable that represents "some type":

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:
        if not self._items:
            raise IndexError("Stack is empty")
        return self._items.pop()

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

# Type-safe: mypy knows this is a Stack of ints
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
print(int_stack.pop())  # 2

# And this is a Stack of strings
str_stack: Stack[str] = Stack()
str_stack.push("hello")
print(str_stack.pop())  # hello
Output
2
hello

Protocol — Structural Subtyping

Protocol (Python 3.8+) defines an interface without requiring inheritance. If a class has the right methods, it satisfies the protocol — this is called structural subtyping or "duck typing with type checking":

from typing import Protocol

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

class Circle:
    def draw(self) -> str:
        return "Drawing circle"

class Square:
    def draw(self) -> str:
        return "Drawing square"

def render(shape: Drawable) -> None:
    """Works with ANY object that has a draw() method."""
    print(shape.draw())

render(Circle())   # Works — Circle has draw()
render(Square())   # Works — Square has draw()
# render(42)       # mypy error: int doesn't have draw()
Output
Drawing circle
Drawing square
Key Takeaway: Protocol is Python's answer to Go's interfaces. No inheritance needed — if it has the right methods, it works. This aligns perfectly with Python's "duck typing" philosophy.

Running mypy

# Install
pip install mypy

# Check a file
mypy your_script.py

# Check entire project with strict mode
mypy --strict src/

# Configuration in pyproject.toml
# [tool.mypy]
# python_version = "3.12"
# strict = true
# ignore_missing_imports = true
TypeSyntaxExample
Basicint, str, float, boolx: int = 42
Listlist[T]nums: list[int] = [1, 2]
Dictdict[K, V]d: dict[str, int]
OptionalT | Nonename: str | None = None
UnionT | Uval: int | str
CallableCallable[[args], ret]f: Callable[[int], str]
AnyAnyDisables type checking for this value
Tupletuple[T, ...]point: tuple[int, int]
🔍 Deep Dive: TypedDict and Literal

TypedDict lets you type dictionaries with specific key-value types: class User(TypedDict): name: str; age: int. Literal restricts to specific values: mode: Literal["read", "write"]. These are especially useful for typing JSON-like data and configuration options.

⚠️ Common Mistake: Annotating Everything as Any

Wrong:

from typing import Any

def process(data: Any) -> Any:  # Defeats the purpose!
    return data

Why: Any disables type checking entirely. It's the escape hatch, not the default. Using it everywhere is like turning off spell-check — technically allowed but you lose all the benefits.

Instead: Use specific types, generics, or Protocol to keep type safety.