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:
# 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:
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 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:
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
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:
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:
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:
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:
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:
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
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
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
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
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:
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:
[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
| Tool | Notes |
|---|---|
| mypy | Standard, battle-tested, most compatible |
| pyright / pylance | Microsoft's checker, faster, powers VS Code IntelliSense |
| pytype | Google's checker, infers types without annotations |
| pydantic | Runtime validation using type hints - validates data at runtime |
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.