Python Type Hints — Write Cleaner, Safer Code in 2026
Advertisement
Introduction
Why This Matters
Python type hints have evolved from optional documentation into a professional standard. They make code self-documenting, enable IDE autocompletion and refactoring, catch bugs before runtime with static analysis tools like mypy and pyright, and power frameworks like FastAPI and Pydantic that use type information to generate validation and API schemas automatically.
In 2026, virtually every serious Python project uses type hints. Codebases at major tech companies enforce them via CI linting. When you apply for Python engineering roles, interviewers expect to see typed function signatures. When you contribute to open source, maintainers will ask you to add types if missing.
This guide covers everything from basic annotations to advanced patterns like Protocol, TypeVar, and TypedDict.
Basic Annotations
# Variables
name: str = "Alice"
age: int = 30
price: float = 9.99
is_active: bool = True
# Functions
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
def log(message: str) -> None: # None = no return value
print(message)Built-in Generic Types (Python 3.9+)
Since Python 3.9, you can use built-in types directly as generics without importing from typing.
# Lists, dicts, sets, tuples
def process_names(names: list[str]) -> list[str]:
return [n.upper() for n in names]
def count_words(text: str) -> dict[str, int]:
return {word: text.count(word) for word in text.split()}
def first_last(items: list[int]) -> tuple[int, int]:
return items[0], items[-1]
# Nested
config: dict[str, list[int]] = {"scores": [95, 87, 92]}Optional and Union
from typing import Optional, Union
# Optional[X] means X or None
def find_user(user_id: int) -> Optional[dict]:
users = {1: {"name": "Alice"}}
return users.get(user_id)
# Same using X | None (Python 3.10+)
def find_user_v2(user_id: int) -> dict | None:
return None
# Union — multiple possible types
def normalize(value: Union[int, float, str]) -> float:
return float(value)
# Python 3.10+ syntax
def normalize_v2(value: int | float | str) -> float:
return float(value)TypedDict — Typed Dictionaries
from typing import TypedDict, Required, NotRequired
class UserConfig(TypedDict):
name: str
email: str
age: int
role: NotRequired[str] # optional key
def create_user(config: UserConfig) -> str:
return f"User: {config['name']} ({config['email']})"
user: UserConfig = {"name": "Alice", "email": "alice@example.com", "age": 30}
print(create_user(user))Dataclasses with Type Hints
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Product:
id: int
name: str
price: float
tags: list[str] = field(default_factory=list)
description: Optional[str] = None
def discounted_price(self, rate: float) -> float:
return self.price * (1 - rate)
p = Product(id=1, name="Widget", price=29.99, tags=["sale", "new"])
print(p.discounted_price(0.1)) # 26.991Callable and Higher-Order Functions
from typing import Callable
def apply(func: Callable[[int], int], value: int) -> int:
return func(value)
def make_adder(n: int) -> Callable[[int], int]:
def adder(x: int) -> int:
return x + n
return adder
add_five = make_adder(5)
print(apply(add_five, 10)) # 15TypeVar — Generic Functions
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
"""Return the first element of any list."""
return items[0]
print(first([1, 2, 3])) # 1 (int)
print(first(["a", "b", "c"])) # 'a' (str)Protocol — Structural Typing
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:
print(shape.draw())
# Both work without explicitly inheriting from Drawable
render(Circle()) # Drawing circle
render(Square()) # Drawing squareType Guards
from typing import TypeGuard
def is_string_list(val: list) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
items: list[object] = ["a", "b", "c"]
if is_string_list(items):
# items is now list[str] in this block
print(items[0].upper())Running mypy
pip install mypy
mypy main.py --strictCommon Mistakes
- Using
listinstead oflist[str]for typed lists (missing the generic) - Using
Optional[str]whenstr | Noneis clearer in Python 3.10+ - Annotating
-> Noneon functions that actually return a value - Using
Anyeverywhere to silence type errors without fixing them - Not running a type checker — annotations alone don't enforce types
Best Practices
- Use
str | NoneoverOptional[str]in Python 3.10+ codebases - Run
mypy --strictorpyrightin CI to enforce type correctness - Use
Protocolfor duck-typed interfaces instead of abstract base classes - Use
dataclassor PydanticBaseModelfor structured data over plain dicts - Start with
reveal_type(expr)in mypy to understand inferred types
Key Takeaways
- Type hints are annotations — they do not enforce types at runtime without libraries like Pydantic
- Use built-in generics:
list[str],dict[str, int],tuple[int, ...]in Python 3.9+ Optional[T]is equivalent toT | None— prefer the latter in Python 3.10+TypedDicttypes dictionary shapes;dataclasscreates typed data objectsProtocolenables structural (duck-typed) interfaces for more flexible type checkingmypyandpyrightare the main tools for static type analysis- FastAPI and Pydantic leverage type hints at runtime to generate validation and documentation
Advertisement