Python Type Hints: Complete Guide to Static Typing (2026)
Python's type hint system has matured dramatically since PEP 484. In 2026, type annotations are a first-class part of professional Python — they power IDE autocomplete, catch bugs before runtime, generate API documentation, and enable runtime validation via libraries like Pydantic and beartype. This guide covers every major typing construct from basic annotations to generics, Protocols, and type narrowing.
Table of Contents
Basic Type Hints
Type hints annotate variables, function parameters, and return types. They are not enforced at runtime by default — they are metadata for type checkers and IDEs:
# Variable annotations
name: str = "Alice"
age: int = 30
price: float = 9.99
is_active: bool = True
# Function annotations
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
# No return value
def log_event(event: str) -> None:
print(f"[EVENT] {event}")
# Python 3.10+ — use built-in types directly in hints (no import needed)
def process(items: list[int], mapping: dict[str, float]) -> tuple[int, float]:
total = sum(items)
average = sum(mapping.values()) / len(mapping)
return total, average
list[int], dict[str, int], tuple[str, ...] directly. No need to import List, Dict, Tuple from typing. For Python 3.9 and below, use from __future__ import annotations to enable this syntax.
Optional, Union, and the | Operator
from typing import Optional, Union
# Optional[X] is equivalent to Union[X, None]
def find_user(user_id: int) -> Optional[dict]:
# Returns a dict or None
...
# Union — accepts multiple types
def stringify(value: Union[int, float, str]) -> str:
return str(value)
# Python 3.10+ — use | instead of Union
def find_user_modern(user_id: int) -> dict | None:
...
def stringify_modern(value: int | float | str) -> str:
return str(value)
# Handling Optional correctly
def get_username(user: dict | None) -> str:
if user is None:
return "anonymous"
return user["username"] # type checker knows user is dict here
Annotating Collections
from typing import Sequence, Mapping, Iterable, Iterator, Generator
# list, dict, set, tuple — use built-in generics (Python 3.9+)
def process_names(names: list[str]) -> list[str]:
return [n.upper() for n in names]
def count_words(text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
return counts
# Tuple — fixed length with specific types
def get_coordinates() -> tuple[float, float]:
return 51.5, -0.12
# Tuple — variable length, all same type
def get_scores() -> tuple[int, ...]:
return (95, 87, 91, 78)
# Sequence — read-only, accepts list/tuple/str
def sum_sequence(values: Sequence[float]) -> float:
return sum(values)
# Generator type
def count_up(start: int, end: int) -> Generator[int, None, None]:
current = start
while current <= end:
yield current
current += 1
TypeVar and Generics
TypeVar lets you write functions that work with any type while preserving the relationship between input and output types:
from typing import TypeVar, Generic
T = TypeVar("T")
K = TypeVar("K")
V = TypeVar("V")
# Generic function — return type mirrors the input type
def first(items: list[T]) -> T:
return items[0]
result: int = first([1, 2, 3]) # type checker knows result is int
name: str = first(["a", "b"]) # type checker knows name is str
# Constrained TypeVar — only specific types allowed
Numeric = TypeVar("Numeric", int, float)
def double(value: Numeric) -> Numeric:
return value * 2
# Bounded TypeVar — must be a subclass of the bound
from datetime import date
DateLike = TypeVar("DateLike", bound=date)
def days_until(d: DateLike) -> int:
return (d - date.today()).days
# 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:
return self._items[-1]
def is_empty(self) -> bool:
return len(self._items) == 0
int_stack: Stack[int] = Stack()
int_stack.push(42)
value: int = int_stack.pop()
Protocol for Structural Subtyping
Protocol defines an interface by structure ("duck typing") rather than inheritance. A class satisfies a Protocol if it has the required methods — no explicit registration needed:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...
def resize(self, factor: float) -> None: ...
class Circle:
def __init__(self, radius: float):
self.radius = radius
def draw(self) -> None:
print(f"Drawing circle r={self.radius}")
def resize(self, factor: float) -> None:
self.radius *= factor
class Rectangle:
def __init__(self, w: float, h: float):
self.w, self.h = w, h
def draw(self) -> None:
print(f"Drawing rect {self.w}x{self.h}")
def resize(self, factor: float) -> None:
self.w *= factor
self.h *= factor
def render_all(shapes: list[Drawable]) -> None:
for shape in shapes:
shape.draw()
# Both work — neither inherits from Drawable
render_all([Circle(5.0), Rectangle(3.0, 4.0)])
# runtime_checkable enables isinstance() checks
print(isinstance(Circle(1.0), Drawable)) # True
TypedDict
TypedDict creates dict types with specific keys and value types — great for JSON API responses and config objects:
from typing import TypedDict, Required, NotRequired
class UserDict(TypedDict):
id: int
username: str
email: str
is_active: bool
# With optional keys (Python 3.11+ Required/NotRequired)
class ArticleDict(TypedDict):
id: int
title: str
body: str
tags: NotRequired[list[str]] # optional key
published_at: NotRequired[str] # optional key
def display_user(user: UserDict) -> str:
return f"{user['username']} <{user['email']}>"
# Type checker knows the exact shape — catches typos at check time
user: UserDict = {"id": 1, "username": "alice", "email": "alice@example.com", "is_active": True}
print(display_user(user))
Literal and Final
from typing import Literal, Final
# Literal — restricts to specific values
Direction = Literal["north", "south", "east", "west"]
HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]
def move(direction: Direction, steps: int) -> None:
print(f"Moving {direction} {steps} steps")
move("north", 3) # ok
move("up", 3) # type error: "up" is not a valid Direction
def make_request(method: HttpMethod, url: str) -> None: ...
# Final — marks a variable as a constant (cannot be reassigned)
MAX_CONNECTIONS: Final = 100
API_VERSION: Final[str] = "v2"
BASE_URL: Final = "https://api.techoral.com"
# MAX_CONNECTIONS = 200 # type error: cannot assign to Final variable
Dataclasses with Type Hints
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass
class Config:
# ClassVar — shared across instances, not included in __init__
VERSION: ClassVar[str] = "1.0.0"
host: str
port: int = 8080
debug: bool = False
allowed_origins: list[str] = field(default_factory=list)
@property
def base_url(self) -> str:
return f"http://{self.host}:{self.port}"
config = Config(host="localhost", debug=True)
print(config.base_url) # http://localhost:8080
print(Config.VERSION) # 1.0.0
mypy Usage
pip install mypy
# Run mypy on your project
mypy src/
# Strict mode — enables all checks
mypy src/ --strict
# mypy.ini or pyproject.toml configuration
# [mypy]
# python_version = 3.12
# strict = true
# ignore_missing_imports = true
# exclude = tests/
# Common mypy errors and fixes:
# error: Argument 1 to "greet" has incompatible type "int"; expected "str"
greet(42) # BAD
greet(str(42)) # GOOD
# error: Item "None" of "str | None" has no attribute "upper"
def process(name: str | None) -> str:
return name.upper() # BAD — name might be None
if name is None:
return ""
return name.upper() # GOOD — type narrowed to str
Runtime Validation with beartype
beartype enforces type hints at runtime with near-zero overhead — useful for validating API boundaries and catching bad data in production:
pip install beartype
from beartype import beartype
from beartype.typing import Annotated
from beartype.vale import Is
@beartype
def add(x: int, y: int) -> int:
return x + y
add(1, 2) # ok
add(1, "two") # raises BeartypeCallHintParamViolation at runtime
# Annotated constraints
PositiveInt = Annotated[int, Is[lambda n: n > 0]]
NonEmptyStr = Annotated[str, Is[lambda s: len(s) > 0]]
@beartype
def create_user(name: NonEmptyStr, age: PositiveInt) -> dict:
return {"name": name, "age": age}
create_user("Alice", 25) # ok
create_user("", 25) # raises — name is empty
create_user("Alice", -1) # raises — age is not positive
Type Narrowing
Type narrowing is when the type checker refines a broad type to a specific type based on control flow:
from typing import Union, assert_never
def process(value: int | str | list[int]) -> str:
if isinstance(value, int):
# value is int here
return f"Integer: {value}"
elif isinstance(value, str):
# value is str here
return f"String: {value.upper()}"
else:
# value is list[int] here
return f"List sum: {sum(value)}"
# TypeGuard — custom type narrowing function
from typing import TypeGuard
def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(x, str) for x in val)
def process_strings(items: list[object]) -> None:
if is_string_list(items):
# items is list[str] here
print(", ".join(items))
# assert_never — exhaustiveness checking
from typing import Literal
Status = Literal["pending", "active", "closed"]
def handle_status(status: Status) -> str:
if status == "pending":
return "Waiting..."
elif status == "active":
return "Running"
elif status == "closed":
return "Done"
else:
assert_never(status) # type error if Status gains a new value
Frequently Asked Questions
- Do type hints slow down Python?
- No. Python ignores type hints at runtime by default — they add no execution overhead. Only libraries that explicitly read annotations (Pydantic, beartype, FastAPI) incur any cost, and it is typically tiny.
- What is the difference between Protocol and ABC?
- ABCs use explicit inheritance (
class MyClass(MyABC)). Protocols use structural typing — any class with the right methods satisfies the protocol without inheriting it. Protocols are more flexible and compose better. - Should I use Optional[X] or X | None?
- Prefer
X | Nonein Python 3.10+ projects — it is more concise and readable. UseOptional[X]if you need to support Python 3.9 and below withoutfrom __future__ import annotations. - Can mypy check third-party libraries?
- Only if the library ships type stubs or inline annotations. Many popular libraries (requests, boto3, SQLAlchemy) provide stubs via the
types-*packages on PyPI. Install them:pip install types-requests. - What is the Any type and when should I use it?
Anyopts out of type checking for that value — the type checker assumes it is compatible with everything. Use it sparingly when dealing with truly dynamic data (parsed JSON, legacy code) and narrow the type as early as possible.