Type Hints & Annotations

Python is dynamically typed — you never have to declare that a variable is an int or a str. But since Python 3.5, you can add type hints that document expected types and let tools catch bugs before your code runs.

Type hints are optional, don't affect runtime behavior, and make your code dramatically easier to understand.

Basic Type Hints

Variable Annotations

name: str = "Alice"
age: int = 30
temperature: float = 98.6
is_active: bool = True

Function Annotations

Annotate parameters and return types with -> Type:

def greet(name: str) -> str:
    return f"Hello, {name}!"

def add(a: int, b: int) -> int:
    return a + b

def is_adult(age: int) -> bool:
    return age >= 18

Python Playground
Output
Click "Run" to execute your code

Functions That Return Nothing

Use None as the return type for functions that don't return a value:

def log_message(message: str) -> None:
    print(f"[LOG] {message}")

Common Types

Type Description Example
int Integers 42
float Decimal numbers 3.14
str Text "hello"
bool True/False True
bytes Byte sequences b"data"
None No value None

Container Types

Since Python 3.9, you can use built-in types directly for generic annotations. For older versions, import from typing.

# Python 3.9+ (recommended)
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
coordinates: tuple[float, float] = (10.5, 20.3)
unique_ids: set[int] = {1, 2, 3}

# Variable-length tuples
values: tuple[int, ...] = (1, 2, 3, 4, 5)

# Nested containers
matrix: list[list[int]] = [[1, 2], [3, 4]]
user_data: dict[str, list[str]] = {
    "Alice": ["alice@example.com", "alice2@example.com"],
}

Python Playground
Output
Click "Run" to execute your code

Optional and Union

Optional Values

When a value might be None, use Optional or the modern | None syntax:

from typing import Optional

# Traditional syntax
def find_user(user_id: int) -> Optional[str]:
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)

# Modern syntax (Python 3.10+)
def find_user(user_id: int) -> str | None:
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)

Union Types

When a value can be one of several types:

from typing import Union

# Traditional
def process(value: Union[str, int]) -> str:
    return str(value)

# Modern (Python 3.10+)
def process(value: str | int) -> str:
    return str(value)

Python Playground
Output
Click "Run" to execute your code

Type Aliases

Create aliases for complex types to keep annotations readable:

# Without alias — hard to read
def process_data(
    records: list[dict[str, list[tuple[int, str]]]],
) -> dict[str, int]:
    pass

# With alias — much clearer
Record = dict[str, list[tuple[int, str]]]
Summary = dict[str, int]

def process_data(records: list[Record]) -> Summary:
    pass

For formal type aliases (Python 3.12+), use the type statement:

# Python 3.12+
type Vector = list[float]
type Matrix = list[Vector]
type UserID = int

For earlier versions, use TypeAlias from typing:

from typing import TypeAlias

Vector: TypeAlias = list[float]
Matrix: TypeAlias = list[Vector]

Generics with TypeVar

When a function works with any type but needs to express relationships between input and output types, use TypeVar:

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    """Return the first element of a list."""
    return items[0]

# The return type matches the input type:
# first([1, 2, 3]) -> int
# first(["a", "b"]) -> str

You can constrain type variables:

from typing import TypeVar

# Only allows str or bytes
StrOrBytes = TypeVar("StrOrBytes", str, bytes)

def concat(a: StrOrBytes, b: StrOrBytes) -> StrOrBytes:
    return a + b

# Bounded type variable — must be a subclass of the bound
from typing import TypeVar

Numeric = TypeVar("Numeric", bound=float)

def double(value: Numeric) -> Numeric:
    return value * 2

Python Playground
Output
Click "Run" to execute your code

Key Types from the typing Module

Any

Opt out of type checking for a specific value. Use sparingly:

from typing import Any

def log(message: Any) -> None:
    print(str(message))

Callable

Describe function signatures:

from typing import Callable

# A function that takes two ints and returns a bool
Comparator = Callable[[int, int], bool]

def sort_with(items: list[int], key: Comparator) -> list[int]:
    return sorted(items, key=lambda x: key(x, 0))

# A function with no arguments that returns a string
Factory = Callable[[], str]

Literal

Restrict to specific literal values:

from typing import Literal

def set_mode(mode: Literal["read", "write", "append"]) -> None:
    print(f"Mode set to: {mode}")

# Only these exact strings are valid:
set_mode("read")    # OK
set_mode("write")   # OK
# set_mode("delete")  # Type error!

TypedDict

Define the shape of dictionaries with specific keys:

from typing import TypedDict

class UserProfile(TypedDict):
    name: str
    age: int
    email: str

# Must have exactly these keys with these types
user: UserProfile = {
    "name": "Alice",
    "age": 30,
    "email": "alice@example.com",
}

Python Playground
Output
Click "Run" to execute your code

Protocol Classes: Structural Subtyping

Python's Protocol class (from PEP 544) enables structural subtyping — also known as static duck typing. Instead of requiring inheritance from a specific base class, a Protocol defines what methods and attributes an object must have:

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str:
        ...

# No inheritance needed! Just implement the method.
class Circle:
    def draw(self) -> str:
        return "Drawing a circle"

class Square:
    def draw(self) -> str:
        return "Drawing a square"

def render(shape: Drawable) -> None:
    print(shape.draw())

# Both work because they have a draw() method
render(Circle())
render(Square())

Protocol vs Inheritance-Based Polymorphism

This is a fundamental distinction in Python typing:

Inheritance-based (nominal subtyping):

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        pass

class Circle(Shape):  # Must explicitly inherit
    def area(self) -> float:
        return 3.14 * self.radius ** 2

Protocol-based (structural subtyping):

from typing import Protocol

class HasArea(Protocol):
    def area(self) -> float:
        ...

class Circle:  # No inheritance required
    def area(self) -> float:
        return 3.14 * self.radius ** 2

The key difference: with inheritance, the class must know about the base class and explicitly inherit from it. With protocols, any class that has the right methods and attributes satisfies the protocol automatically, even third-party classes you can't modify.

Approach Mechanism Flexibility Explicitness
ABC/Inheritance Nominal (must inherit) Less flexible Very explicit
Protocol Structural (duck typing) More flexible Implicit

When to use which: Use ABCs when you want to enforce a contract and provide shared implementation. Use Protocols when you want to accept any object with the right interface, especially useful for type-checking code that uses duck typing.

Python Playground
Output
Click "Run" to execute your code

When to Use Type Hints

Type hints are most valuable in certain contexts:

Always Use Them For:

  • Public APIs — library functions, class interfaces, module exports
  • Complex functions — where parameter types aren't obvious
  • Team projects — helps everyone understand the codebase
  • Long-lived code — you'll forget what types things are

Optional For:

  • Simple scripts — a 20-line script may not benefit
  • Obvious typesname = "Alice" doesn't need name: str = "Alice"
  • Internal helpers — small private functions used in one place
  • Prototyping — add types after the design stabilizes

Gradual Adoption

You don't have to annotate everything at once. Start with:

  1. Public function signatures
  2. Complex function signatures
  3. Class attributes
  4. Variables with non-obvious types
# Start here: annotate public functions
def calculate_discount(price: float, percentage: float) -> float:
    return price * (1 - percentage / 100)

# Then add class annotations
class Order:
    items: list[str]
    total: float

    def __init__(self, items: list[str]) -> None:
        self.items = items
        self.total = 0.0

Static Type Checkers

Type hints are only documentation unless you use a tool to verify them.

mypy

The original and most widely used type checker:

# Install
pip install mypy

# Check a file
mypy my_module.py

# Check a project
mypy src/

# Common flags
mypy --strict src/           # Maximum strictness
mypy --ignore-missing-imports src/  # Skip untyped third-party libs

pyright

Microsoft's type checker, also powers Pylance in VS Code. Faster than mypy and often catches more issues:

# Install
pip install pyright

# Check a project
pyright src/

Configuration

Both tools can be configured in pyproject.toml:

# mypy configuration
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true

# pyright configuration
[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "strict"

PEP 484 and PEP 526

The key PEPs that define Python's type hinting system:

  • PEP 484 (2014) — introduced type hints for function annotations
  • PEP 526 (2016) — added variable annotations (x: int = 5)
  • PEP 544 (2017) — introduced Protocol classes
  • PEP 604 (2020) — the X | Y union syntax
  • PEP 612 (2020) — ParamSpec for decorator typing
  • PEP 695 (2023) — the type statement and cleaner generic syntax

The type system continues to evolve with each Python release, consistently making annotations more expressive and easier to write.

Python Playground
Output
Click "Run" to execute your code