Python Type Hints

Annotate your Python code for better tooling, clearer contracts, and fewer bugs - without sacrificing runtime flexibility.

Intermediate

Type Hint Basics

Type hints use : for variables and parameters, -> for return types. They are annotations - Python stores them but does not enforce them at runtime:

Python
# Variable annotations
name: str = 'Alice'
age:  int = 30
pi:   float = 3.14

# Function annotations
def greet(name: str, times: int = 1) -> str:
    return (f'Hello, {name}! ' * times).strip()

# Access annotations at runtime
print(greet.__annotations__)
# {'name': , 'times': , 'return': }

# Annotations don't enforce anything
def add(x: int, y: int) -> int:
    return x + y

add('a', 'b')   # no error at runtime - returns 'ab'

Use from __future__ import annotations (or Python 3.10+) to enable lazy evaluation of annotations - forward references work without quotes:

Python
from __future__ import annotations  # all annotations are strings at runtime

class Node:
    def __init__(self, value: int, next: Node | None = None):
        # Without the import, 'Node' would raise NameError
        # (class not yet defined when annotation is evaluated)
        self.value = value
        self.next  = next

Built-in and Collection Types

Since Python 3.9+, you can use built-in types directly as generics. For older versions, import equivalents from typing:

Python
# Python 3.9+ - use built-in types directly
def process(items: list[int]) -> dict[str, int]:
    return {str(i): i for i in items}

def merge(a: dict[str, list[int]], b: dict[str, list[int]]) -> None:
    for k, v in b.items():
        a.setdefault(k, []).extend(v)

# Tuple: fixed length, each position typed
def point() -> tuple[int, int]: ...
# Variable-length homogeneous tuple
def coords() -> tuple[float, ...]: ...

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

# Python 3.8 and earlier - import from typing
from typing import List, Dict, Tuple, Set
def old_style(items: List[int]) -> Dict[str, int]:
    return {str(i): i for i in items}

Common type aliases make complex signatures readable:

Python
from typing import TypeAlias

# Python 3.10+ explicit alias
Vector: TypeAlias = list[float]
Matrix: TypeAlias = list[list[float]]
JsonValue: TypeAlias = str | int | float | bool | None | list | dict

def dot_product(a: Vector, b: Vector) -> float:
    return sum(x * y for x, y in zip(a, b))

def transform(matrix: Matrix, vector: Vector) -> Vector:
    return [dot_product(row, vector) for row in matrix]

Optional and Union

Python
from typing import Optional, Union

# Optional[X] means X or None
def find_user(user_id: int) -> Optional[dict]:
    # ...
    return None  # or a dict

# Python 3.10+ shorthand - preferred
def find_user_new(user_id: int) -> dict | None:
    return None

# Union - one of several types
def parse(value: Union[str, int, float]) -> float:
    return float(value)

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

# Narrowing with isinstance - type checker understands this
def process(value: str | int) -> str:
    if isinstance(value, int):
        return str(value)     # type checker knows value is int here
    return value.upper()      # type checker knows value is str here

Use typing.Any as an escape hatch when the type is truly unknown:

Python
from typing import Any

# Any is compatible with all types - use sparingly
def log(value: Any) -> None:
    print(repr(value))

# cast() tells the type checker "trust me, this is X"
from typing import cast
data: Any = get_data()
typed_data = cast(dict[str, int], data)   # runtime no-op, type checker sees dict[str, int]

TypeVar and Generic

TypeVar allows writing generic functions that preserve type information:

Python
from typing import TypeVar

T = TypeVar('T')

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

n = first([1, 2, 3])      # type checker knows n: int
s = first(['a', 'b'])     # type checker knows s: str

# Bounded TypeVar - T must be a subclass of Comparable
from typing import TypeVar
C = TypeVar('C', bound='Comparable')

# Constrained TypeVar - T must be exactly one of these types
NumT = TypeVar('NumT', int, float)

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

# Python 3.12+ new syntax (PEP 695)
# def first[T](items: list[T]) -> T:  # no TypeVar import needed
#     return items[0]

Generic classes:

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

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

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

int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
val = int_stack.pop()   # type checker knows val: int

Protocol (Structural Typing)

Protocol defines a structural interface. Any class with the right attributes/methods satisfies the protocol - no explicit inheritance needed:

Python
from typing import Protocol, runtime_checkable

class Drawable(Protocol):
    def draw(self) -> None: ...
    def resize(self, factor: float) -> None: ...

class Circle:
    def draw(self) -> None: print('drawing circle')
    def resize(self, factor: float) -> None: print(f'resize by {factor}')

class Square:
    def draw(self) -> None: print('drawing square')
    def resize(self, factor: float) -> None: print(f'resize by {factor}')

# Neither inherits from Drawable - but both satisfy it
def render_all(shapes: list[Drawable]) -> None:
    for shape in shapes:
        shape.draw()

render_all([Circle(), Square()])   # type checker accepts this

# @runtime_checkable makes isinstance() work with Protocols
@runtime_checkable
class Sized(Protocol):
    def __len__(self) -> int: ...

print(isinstance([1, 2, 3], Sized))   # True
print(isinstance('hello', Sized))     # True
print(isinstance(42, Sized))          # False

Protocol with class variables and properties:

Python
from typing import Protocol, ClassVar

class SupportsClose(Protocol):
    def close(self) -> None: ...

class SupportsRead(Protocol):
    def read(self, n: int = -1) -> str: ...

# Combine protocols
class TextIO(SupportsRead, SupportsClose, Protocol):
    def write(self, s: str) -> int: ...

def process_stream(stream: TextIO) -> str:
    try:
        return stream.read()
    finally:
        stream.close()

Advanced Types

TypedDict

Python
from typing import TypedDict, Required, NotRequired

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

# NotRequired fields (Python 3.11+)
class Config(TypedDict, total=False):   # total=False: all keys optional
    host: str
    port: int

class Config2(TypedDict):
    host:    str           # required
    port:    NotRequired[int]   # optional

user: User = {'id': 1, 'name': 'Alice', 'email': 'alice@example.com'}
config: Config2 = {'host': 'localhost'}   # port is optional

Literal

Python
from typing import Literal

Mode = Literal['r', 'w', 'a', 'rb', 'wb']

def open_file(path: str, mode: Mode) -> None:
    pass

open_file('data.txt', 'r')    # ok
# open_file('data.txt', 'x')  # type error - 'x' not in Literal

Direction = Literal['north', 'south', 'east', 'west']
Status    = Literal[0, 1, -1]

overload

Python
from typing import overload

@overload
def process(x: int) -> int: ...
@overload
def process(x: str) -> str: ...

def process(x):   # actual implementation - untyped or typed broadly
    if isinstance(x, int):
        return x * 2
    return x.upper()

n = process(5)        # type checker knows n: int
s = process('hello')  # type checker knows s: str

Callable and ParamSpec

Python
from typing import Callable, ParamSpec, TypeVar
import functools

P = ParamSpec('P')
T = TypeVar('T')

# Callable[[arg_types], return_type]
def apply(func: Callable[[int], str], value: int) -> str:
    return func(value)

# ParamSpec preserves the original function's signature in decorators
def logged(func: Callable[P, T]) -> Callable[P, T]:
    @functools.wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
        print(f'calling {func.__name__}')
        return func(*args, **kwargs)
    return wrapper

@logged
def add(x: int, y: int) -> int:
    return x + y

result = add(1, 2)   # type checker knows result: int

Running mypy

mypy is the standard Python type checker. Install and run it on your project:

Bash
pip install mypy

# Check a file
mypy mymodule.py

# Check entire package
mypy src/

# Strict mode - enables all optional checks
mypy --strict mymodule.py

Configure mypy with mypy.ini or pyproject.toml:

Python
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true

# Per-module overrides for third-party libs without stubs
[[tool.mypy.overrides]]
module = "some_untyped_library.*"
ignore_missing_imports = true
ToolNotes
mypyStandard, battle-tested, most compatible
pyright / pylanceMicrosoft's checker, faster, powers VS Code IntelliSense
pytypeGoogle's checker, infers types without annotations
pydanticRuntime validation using type hints - validates data at runtime
Gradual typing - start small

You don't have to annotate everything at once. Start by annotating public APIs and function signatures. Use Any for complex internal types you'll annotate later. Run mypy --ignore-missing-imports initially to avoid noise from third-party libraries. Add py.typed (an empty marker file) to your package to signal that it supports type checking.

Frequently Asked Questions

No. Type hints are annotations only - Python ignores them at runtime. They are checked by static analysis tools like mypy, pyright, and IDE type checkers. Use isinstance() if you need runtime validation. Libraries like pydantic use type hints at runtime for data validation, but they do this explicitly via their own machinery.

They are identical. Optional[X] is shorthand for Union[X, None]. In Python 3.10+, you can write X | None instead. Prefer the newer X | None syntax in Python 3.10+ codebases for clarity. In earlier versions, use Optional[X] from typing.

Protocol enables structural subtyping ("duck typing" with type checking). A class satisfies a Protocol if it has the right methods/attributes - it does not need to inherit from the Protocol. Use Protocol when you want to accept any object with a particular interface without requiring inheritance. It is the typed equivalent of Python's duck typing philosophy.

TypeVar declares a type variable for generic functions and classes. It tells the type checker "this can be any type, but the same type throughout." For example, def first(lst: list[T]) -> T says "whatever type the list contains, that's what I return." Without TypeVar you'd have to use Any, losing type information.