Toolmingo
Guides7 min read

Python Type Hints: A Complete Guide to Type Annotations

A complete guide to Python type hints — variable annotations, function signatures, generics, Union, Optional, TypedDict, Protocol, dataclasses, and mypy usage with copy-ready examples.

Python type hints (introduced in PEP 484) let you annotate variables, function parameters, and return values with expected types. They are optional at runtime but invaluable for catching bugs early with tools like mypy, pyright, and IDE auto-complete.

Quick reference

Annotation Example
Variable x: int = 42
Function param def f(n: int) -> str:
Optional def f(x: int | None)
Union int | str
List list[int]
Dict dict[str, int]
Tuple (fixed) tuple[int, str]
Tuple (variadic) tuple[int, ...]
Set set[str]
Callable Callable[[int, str], bool]
Any from typing import Any
TypeVar T = TypeVar("T")
Generic class class Stack[T]: (3.12+)
TypedDict class User(TypedDict):
Protocol class Drawable(Protocol):
Literal Literal["GET", "POST"]
Final MAX: Final = 100
ClassVar count: ClassVar[int] = 0
Self def copy(self) -> Self:

Basic annotations

# Variables (Python 3.6+)
name: str = "Alice"
age: int = 30
score: float = 9.5
active: bool = True
data: bytes = b"\x00\xff"

# Annotation without assignment (declares type only)
user_id: int

# Built-in collection types (Python 3.9+)
names: list[str] = ["Alice", "Bob"]
mapping: dict[str, int] = {"a": 1, "b": 2}
unique: set[int] = {1, 2, 3}
pair: tuple[str, int] = ("Alice", 30)
matrix: list[list[float]] = [[1.0, 2.0], [3.0, 4.0]]

# Python 3.8 and earlier — use typing module
from typing import List, Dict, Set, Tuple
names: List[str] = ["Alice"]
mapping: Dict[str, int] = {"a": 1}

Function annotations

# Parameters and return type
def greet(name: str, times: int = 1) -> str:
    return f"Hello, {name}! " * times

# No return value
def log(message: str) -> None:
    print(message)

# Multiple parameters
def distance(x1: float, y1: float, x2: float, y2: float) -> float:
    return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5

# *args and **kwargs
def total(*args: int) -> int:
    return sum(args)

def configure(**kwargs: str) -> dict[str, str]:
    return dict(kwargs)

# Callable type
from typing import Callable
transform: Callable[[int], int] = lambda x: x * x

Optional and Union

# Python 3.10+ — use | for unions
def find_user(user_id: int) -> dict[str, str] | None:
    ...

def process(value: int | str | float) -> str:
    return str(value)

# Python 3.9 and earlier — use Optional and Union
from typing import Optional, Union

def find_user(user_id: int) -> Optional[dict[str, str]]:
    # Optional[X] is shorthand for Union[X, None]
    ...

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

# Narrowing with isinstance
def double(value: int | str) -> int | str:
    if isinstance(value, int):
        return value * 2      # int here
    return value + value      # str here

# None checks narrow automatically
def length(s: str | None) -> int:
    if s is None:
        return 0
    return len(s)             # s is str here

TypeVar and generics

from typing import TypeVar

T = TypeVar("T")

# Generic function — T is inferred at call site
def first(items: list[T]) -> T | None:
    return items[0] if items else None

result = first([1, 2, 3])    # inferred: int | None
text   = first(["a", "b"])   # inferred: str | None

# Bounded TypeVar — T must be a subtype of a given type
NumberT = TypeVar("NumberT", int, float)

def maximum(a: NumberT, b: NumberT) -> NumberT:
    return a if a > b else b

# Python 3.12 — new syntax (no TypeVar import needed)
def first_312[T](items: list[T]) -> T | None:
    return items[0] if items else None

# Generic class (Python 3.12)
class Stack[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 | None:
        return self._items[-1] if self._items else None

# Generic class (Python 3.9-3.11)
from typing import Generic

class Repository(Generic[T]):
    def __init__(self) -> None:
        self._store: dict[int, T] = {}

    def save(self, id: int, item: T) -> None:
        self._store[id] = item

    def get(self, id: int) -> T | None:
        return self._store.get(id)

TypedDict

from typing import TypedDict, NotRequired

# All keys required by default
class User(TypedDict):
    id: int
    name: str
    email: str

# Mix required and optional (Python 3.11+)
class Product(TypedDict):
    id: int
    name: str
    price: float
    description: NotRequired[str]   # optional key

# Alternative: total=False makes all keys optional
class UserUpdate(TypedDict, total=False):
    name: str
    email: str
    bio: str

def update_user(user_id: int, data: UserUpdate) -> None:
    ...

update_user(1, {"name": "Bob"})                   # valid
update_user(1, {"name": "Bob", "bio": "Hi"})      # valid

# Nested TypedDict
class Address(TypedDict):
    street: str
    city: str
    country: str

class UserWithAddress(TypedDict):
    id: int
    name: str
    address: Address

Protocol (structural subtyping)

from typing import Protocol, runtime_checkable

# Define behaviour via Protocol — no inheritance needed
class Drawable(Protocol):
    def draw(self) -> None: ...
    def resize(self, factor: float) -> None: ...

class Circle:
    def draw(self) -> None:
        print("Drawing circle")

    def resize(self, factor: float) -> None:
        self.radius *= factor

class Square:
    def draw(self) -> None:
        print("Drawing square")

    def resize(self, factor: float) -> None:
        self.side *= factor

# Both Circle and Square satisfy Drawable without inheriting it
def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())   # OK
render(Square())   # OK

# runtime_checkable enables isinstance() checks
@runtime_checkable
class Sized(Protocol):
    def __len__(self) -> int: ...

print(isinstance([1, 2], Sized))   # True
print(isinstance(42, Sized))       # False

Literal and Final

from typing import Literal, Final

# Literal — restrict to specific values
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
StatusCode = Literal[200, 201, 400, 401, 403, 404, 500]

def make_request(method: HttpMethod, url: str) -> None:
    ...

make_request("GET", "/users")        # OK
# make_request("CONNECT", "/users") # mypy error

# Final — constant that cannot be reassigned
MAX_RETRIES: Final = 3
API_URL: Final[str] = "https://api.example.com"

# MAX_RETRIES = 5  # mypy error: cannot assign to final name

# Final in classes
class Config:
    DEBUG: Final[bool] = False
    VERSION: Final = "1.0.0"

Callable types

from typing import Callable

# Callable[[arg_types], return_type]
Predicate = Callable[[int], bool]
Transformer = Callable[[str, int], str]

def filter_items(items: list[int], pred: Predicate) -> list[int]:
    return [x for x in items if pred(x)]

evens = filter_items([1, 2, 3, 4], lambda x: x % 2 == 0)

# Callable with no args
Thunk = Callable[[], int]

# Generic higher-order function
T = TypeVar("T")

def apply_twice(fn: Callable[[T], T], value: T) -> T:
    return fn(fn(value))

result = apply_twice(lambda x: x + 1, 5)  # 7

Dataclasses with type hints

from dataclasses import dataclass, field
from typing import ClassVar

@dataclass
class Point:
    x: float
    y: float

    def distance_to_origin(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

@dataclass
class User:
    id: int
    name: str
    email: str
    tags: list[str] = field(default_factory=list)
    _count: ClassVar[int] = 0  # class variable, not an instance field

    def __post_init__(self) -> None:
        User._count += 1
        self.name = self.name.strip()

@dataclass(frozen=True)   # immutable
class Color:
    r: int
    g: int
    b: int

    def to_hex(self) -> str:
        return f"#{self.r:02x}{self.g:02x}{self.b:02x}"

red = Color(255, 0, 0)
# red.r = 128  # FrozenInstanceError at runtime

Running mypy

# Install
pip install mypy

# Check a file
mypy script.py

# Check a package
mypy src/

# Strict mode (recommended for new projects)
mypy --strict src/

# Common flags
mypy --ignore-missing-imports src/   # ignore missing type stubs
mypy --show-error-codes src/         # show error codes (e.g. [arg-type])
mypy --pretty src/                   # formatted output

Example mypy.ini configuration:

[mypy]
python_version = 3.12
strict = True
ignore_missing_imports = True

Common mistakes

Mistake Problem Fix
Using list without subscript on Python 3.8 list is not generic at runtime Import List from typing or upgrade to 3.9+
Optional[X] mixed with X | None Inconsistent style Pick one convention per project
Forgetting -> None on void functions Implicit None — be explicit Always annotate return type
Using Any everywhere Defeats the purpose of type hints Use proper generics or Unknown
Mutable default in TypedDict values Not a TypedDict issue but a dataclass default Use field(default_factory=list) in dataclasses
# type: ignore abuse Silences errors permanently Fix the root cause instead
Not running mypy in CI Annotations drift out of sync with code Add mypy --strict step to CI pipeline

FAQ

Are type hints enforced at runtime?
No. Python ignores them at runtime unless you use libraries like pydantic or beartype. They exist solely for static analysis tools (mypy, pyright) and IDE features.

What is the difference between Any and object?
Any is bi-directionally compatible with every type — you can pass it anywhere and call anything on it without errors. object is the root class of all Python objects but is not assignable to specific types without narrowing. Avoid Any; use narrowing with isinstance instead.

Should I annotate every variable?
No. Annotate function signatures and class attributes. For local variables inside functions, type inference usually works correctly. Only annotate locals when inference fails or the type is ambiguous.

What is Self type (Python 3.11+)?
Self refers to the current class, useful for methods that return self (builder pattern, copy methods). Before Python 3.11, you had to use TypeVar("T", bound="MyClass") as a workaround.

What is the difference between Protocol and ABC?
ABC (Abstract Base Class) requires explicit inheritance. Protocol uses structural subtyping — any class with the required methods satisfies the protocol automatically, with no inheritance needed. Protocols are more flexible and Pythonic.

How do I type a dictionary with mixed value types?
Use TypedDict for a fixed set of keys with specific types. For a dynamic mapping, use dict[str, Any] or a union: dict[str, int | str | None].

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools