Type hints in Python are optional but transformative. In 2026, the gap between "I write Python scripts" and "I write production Python" is largely about how you use types.
The basics
def greet(name: str) -> str:
return f"Hello, {name}"
age: int = 30
prices: list[float] = [1.99, 2.49, 0.79]
Type hints don't affect runtime — Python ignores them. They're checked by static analyzers (Pyright, mypy) at edit/CI time.
The vocabulary
from typing import Optional, Union, Any, Callable
x: int # int
x: int | None # int or None (Python 3.10+)
x: Optional[int] # equivalent to int | None
x: int | str # union
x: list[int] # list of ints
x: dict[str, int] # dict
x: tuple[int, str, float] # fixed-length tuple
x: tuple[int, ...] # variable-length tuple
x: set[str]
x: Callable[[int, str], bool] # function taking int+str, returning bool
x: Any # disables checking (use sparingly)
Optional[X] is shorthand for X | None. Prefer X | None in modern code (clearer).
Type aliases
For clarity:
UserId = int
EmailAddress = str
def get_user(id: UserId) -> dict:
...
In Python 3.12+:
type UserId = int # the new syntax
Generics
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()
s: Stack[int] = Stack()
s.push(1)
s.push("hi") # Pyright/mypy: error — expected int
Python 3.12+ syntax:
class Stack[T]:
...
Cleaner; no TypeVar boilerplate.
Protocols (structural typing / duck typing with types)
from typing import Protocol
class Comparable(Protocol):
def __lt__(self, other: 'Comparable') -> bool: ...
def sort_items(items: list[Comparable]) -> list[Comparable]:
return sorted(items)
Any type that has __lt__ is "Comparable" — no inheritance required. Pythonic.
Literal types
from typing import Literal
def set_mode(mode: Literal["dev", "test", "prod"]) -> None:
...
set_mode("dev") # OK
set_mode("staging") # error
For enum-like string constants.
Type-only imports
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from somewhere import HeavyClass
def func(x: 'HeavyClass') -> None:
...
Avoids importing heavy modules at runtime; types still checked at static time. Useful for circular imports.
Gradual typing strategy
Don't try to type your entire codebase at once. Strategy:
- Start with new code. All new functions get types.
- Type public APIs first. Function signatures (parameters and return types). Internal logic later.
- Use
Anyas a temporary escape hatch. Better than no types at all; refine later. - Pyright in "basic" mode initially. Tighten to "strict" gradually.
- Type modules one at a time. Add
# type: ignorefor stubborn lines; revisit.
Any defeats the type checker for that variable; use only when you have to. Common cases: external library returns, dynamic JSON, deserialization.
Tools
Pyright (recommended)
Microsoft's type checker. Fast, used by VS Code's Pylance. Strict mode catches the most.
pip install pyright
pyright src/
Config in pyproject.toml:
[tool.pyright]
include = ["src"]
exclude = ["**/__pycache__"]
typeCheckingMode = "basic" # or "strict"
reportMissingImports = true
mypy
The original. Slower than Pyright but more configurable.
pip install mypy
mypy src/
Config in pyproject.toml:
[tool.mypy]
strict = true
files = ["src"]
Use either. Pyright is the modern default; mypy is fine if you're already on it.
Common errors and fixes
"Argument missing for parameter"
def f(x: int, y: int) -> int: ...
f(1) # error: missing y
Fix: pass all required arguments.
"Incompatible type"
def f(x: int) -> None: ...
f("hello") # error: expected int
Fix: cast or refactor.
"Optional member access"
x: int | None
x + 1 # error: x might be None
Fix: narrow with a check:
if x is not None:
x + 1 # OK
"Type 'X' has no attribute 'Y'"
Common with Union types:
x: int | str
x.upper() # error: int has no .upper()
Fix: type-narrow with isinstance:
if isinstance(x, str):
x.upper()
What types DON'T do
- Don't catch logic errors (a function returning the wrong number is still wrong-by-type-OK).
- Don't replace tests.
- Don't run at runtime (Pydantic-style runtime validation is different).
- Don't always work for highly dynamic code (decorators, metaclasses).
But types catch a real class of bugs: passing wrong arguments, forgetting None checks, calling wrong methods. Cheaper than discovering at runtime.
When NOT to type
- One-off scripts.
- Highly dynamic code (deserialization, plugin systems) — selective
Any. - Code that depends on un-typed libraries —
# type: ignoreuntil library adds types.
Common type-hint mistakes
- Over-typing trivial code.
x: int = 1is noise; type inference handles it. Anyeverywhere. Defeats the purpose.- Type only the function body, not signatures. Signatures matter most.
- Ignoring errors with
# type: ignore. Should fix, not silence. - Forgetting types make IDE smarter. Autocomplete + jump-to-definition improve with types.
Takeaway
Type hints + Pyright/mypy = catch bugs before runtime. Gradual: type public APIs first; new code always; use Any as escape hatch; tighten over time. Modern syntax (Python 3.10+): X | None, list[int], class Stack[T]. Protocols for structural typing. Pyright for new projects; mypy if you're already on it. Free productivity boost in IDE.
Production Note: TypedDict & Partial Payloads (total=False)
When handling external JSON or dictionary payloads where some keys are required and others are optional, standard dict[str, Any] eliminates static typing benefits. Standard TypedDict makes all keys required by default.
To specify optional fields, use total=False on the class definition, or combine with Required and NotRequired (PEP 655):
from typing import Any, NotRequired, Required, TypedDict
class UserPayload(TypedDict, total=False):
# total=False makes fields optional by default
id: Required[int] # Explicitly required
name: Required[str] # Explicitly required
metadata: NotRequired[dict[str, Any]] # Explicitly optional
When total=False, omitting an unrequired key does not trigger static type errors.