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.

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
Python 3.10+: You can use built-in 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 | None in Python 3.10+ projects — it is more concise and readable. Use Optional[X] if you need to support Python 3.9 and below without from __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?
Any opts 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.