Why Python Type Hints Matter
Python is dynamically typed, but type hints (added in Python 3.5, with major improvements through 3.12+) let you annotate your code with expected types. They don't affect runtime behavior — Python still ignores them at execution time — but they enable:
- Static type checking with mypy, Pyright, or similar tools
- Better IDE support — autocomplete, refactoring, and inline documentation
- Self-documenting APIs — function signatures communicate intent without needing docstrings
- Catching bugs early — before your code ever runs
Basic Annotations
# Variables
name: str = "Alice"
age: int = 30
score: float = 9.8
active: bool = True
# Function parameters and return types
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
def process(items: list) -> None: # no return value
for item in items:
print(item)
Built-in Collection Types (Python 3.9+)
# Python 3.9+ — use built-in types directly
def get_ids() -> list[int]:
return [1, 2, 3]
def get_config() -> dict[str, str]:
return {"host": "localhost", "port": "5432"}
def get_scores() -> tuple[int, int, int]:
return (95, 87, 92)
def get_unique_tags() -> set[str]:
return {"python", "typing", "tutorial"}
# Python 3.8 and below — use typing module
from typing import List, Dict, Tuple, Set
def get_ids() -> List[int]: ...
def get_config() -> Dict[str, str]: ...
Optional and Union Types
from typing import Optional, Union # or use | in Python 3.10+
# Optional[T] is equivalent to Union[T, None]
def find_user(user_id: int) -> Optional[str]: # str or None
if user_id > 0:
return "Alice"
return None
# Python 3.10+ syntax
def find_user(user_id: int) -> str | None: # equivalent
...
# Union: can be multiple types
def process(value: Union[int, str]) -> str:
return str(value)
# Python 3.10+ union syntax
def process(value: int | str) -> str:
return str(value)
# Common mistake — never do this for nullable
def bad(name: str = None) -> None: # type checkers will complain
pass
def good(name: str | None = None) -> None: # correct
pass
TypedDict — Typed Dictionaries
from typing import TypedDict
class UserDict(TypedDict):
id: int
name: str
email: str
class PartialUserDict(TypedDict, total=False): # all keys optional
name: str
email: str
def create_user(data: UserDict) -> UserDict:
return data
# Type-safe: mypy checks key names and value types
user: UserDict = {
"id": 1,
"name": "Alice",
"email": "alice@example.com",
}
create_user({"id": 1, "name": "Alice"}) # ❌ mypy error: missing email
create_user({"id": "1", "name": "Alice", "email": "a@b.com"}) # ❌ id must be int
Generics
from typing import TypeVar, Generic
T = TypeVar('T')
K = TypeVar('K')
V = TypeVar('V')
# Generic function
def first(items: list[T]) -> T | None:
return items[0] if items else None
result = first([1, 2, 3]) # inferred type: int | None
result = first(["a", "b"]) # inferred type: str | None
# Generic class
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
stack: Stack[int] = Stack()
stack.push(1)
stack.push("hello") # ❌ mypy error: expected int, got str
Protocols — Structural Typing (Duck Typing)
from typing import Protocol
# Define what a type must be able to do
class Drawable(Protocol):
def draw(self) -> None: ...
class Serializable(Protocol):
def to_json(self) -> str: ...
# Any class with a draw() method satisfies Drawable — no inheritance needed
class Circle:
def draw(self) -> None:
print("Drawing circle")
class Square:
def draw(self) -> None:
print("Drawing square")
def render_all(shapes: list[Drawable]) -> None:
for shape in shapes:
shape.draw()
# ✅ Works because both Circle and Square have draw()
render_all([Circle(), Square()])
# Useful for third-party types you can't modify
class FileWriter(Protocol):
def write(self, data: bytes) -> int: ...
def close(self) -> None: ...
def save_data(writer: FileWriter, data: bytes) -> None:
writer.write(data)
writer.close()
Callable Types
from typing import Callable
# A function that takes int and returns str
def apply(func: Callable[[int], str], value: int) -> str:
return func(value)
# Variable number of arguments
def run(callback: Callable[..., None]) -> None:
callback()
# Returning a callable (higher-order function)
def make_multiplier(factor: int) -> Callable[[int], int]:
def multiply(x: int) -> int:
return x * factor
return multiply
Literal Types
from typing import Literal
Direction = Literal['north', 'south', 'east', 'west']
HttpMethod = Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
def move(direction: Direction) -> None:
print(f"Moving {direction}")
move('north') # ✅
move('diagonal') # ❌ mypy error: not a valid Direction
def make_request(method: HttpMethod, url: str) -> None: ...
dataclasses and attrs with Types
from dataclasses import dataclass, field
@dataclass
class Product:
id: int
name: str
price: float
tags: list[str] = field(default_factory=list)
active: bool = True
def discount_price(self, pct: float) -> float:
return self.price * (1 - pct)
p = Product(id=1, name="Widget", price=9.99)
p.discount_price(0.1) # 8.991
Type Narrowing
def process(value: int | str) -> str:
if isinstance(value, int):
# mypy now knows value: int in this branch
return str(value * 2)
else:
# mypy now knows value: str in this branch
return value.upper()
# assert narrows type
def get_name(user: dict | None) -> str:
assert user is not None, "user must not be None"
# mypy knows user: dict here
return user.get("name", "")
# TypeGuard for custom type narrowing functions
from typing import TypeGuard
def is_string_list(lst: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in lst)
def join_strings(items: list[object]) -> str:
if is_string_list(items):
return ", ".join(items) # mypy knows items: list[str]
return ""
Running mypy
# Install
pip install mypy
# Check a file
mypy script.py
# Check with strict mode (recommended for new projects)
mypy --strict script.py
# Common flags
mypy --ignore-missing-imports # skip untyped third-party libs
mypy --check-untyped-defs # type-check functions without annotations
# pyproject.toml config
[tool.mypy]
strict = true
ignore_missing_imports = true
Frequently Asked Questions
Q: Do type hints slow down Python?
No. Python ignores annotations at runtime (they're stored as metadata, not evaluated by default). The from __future__ import annotations import makes them strings, which has even less overhead.
Q: Should I annotate every variable?
Only annotate where the type isn't obvious from context. Function signatures should always be annotated. Variable assignments where the type is clear from the value (e.g., x = 5) don't need annotation.
Q: How do I handle third-party libraries without type stubs?
Use # type: ignore on specific lines, py.typed stubs, or install stub packages (pip install types-requests). Check typeshed for community-maintained stubs.
→ Explore and validate Python data structures with the JSON Viewer.