Polyglot
Languages Python type hints
Python § type-hints

Type hints

Python is dynamically typed at runtime, but since Python 3.5 (PEP 484) the language admits type hints — optional, opt-in static annotations on parameters, return values, and variables. The runtime ignores the hints; they exist for the benefit of external type checkers (mypy, pyright, basedpyright), IDEs, and documentation tools. The mechanism is gradual typing: a codebase can adopt hints incrementally, with un-annotated code interoperating freely with annotated code. Modern Python codebases use type hints extensively; the type checkers have become substantially capable, and the typing ecosystem has grown to cover most of the language.

This page covers the type-hint syntax, the principal types, generics, the typing module, and the conventional discipline. The duck-typing side of Python’s typing story — the dynamic protocols and the typing.Protocol machinery — is in Duck typing and protocols.

The basic syntax

Type hints attach to names with a colon:

n: int = 42
name: str = "Alice"
ages: list[int] = [25, 30, 35]
mapping: dict[str, int] = {"a": 1, "b": 2}
optional_value: int | None = None

For function parameters and return values:

def greet(name: str, age: int = 0) -> str:
    return f"Hello, {name}, age {age}"

def process(items: list[str]) -> dict[str, int]:
    return {item: len(item) for item in items}

The type after : is the parameter’s expected type; the type after -> is the return type. The annotations are introspectable through function.__annotations__:

greet.__annotations__
# {'name': <class 'str'>, 'age': <class 'int'>, 'return': <class 'str'>}

The runtime does not check the types: greet(123, "abc") runs without error (and produces nonsense output), unless a type checker has been run against the code.

The principal types

Built-ins

Modern Python (since 3.9) admits using built-in container types as generics directly:

xs: list[int]                    # PEP 585; since 3.9
ys: dict[str, int]
zs: tuple[int, str, bool]
ws: set[str]
fs: frozenset[int]

For Python 3.8 and earlier, the same names are imported from typing:

from typing import List, Dict, Tuple, Set, FrozenSet

xs: List[int]
ys: Dict[str, int]
zs: Tuple[int, str, bool]

The from typing form is deprecated in modern code but remains widely used in legacy projects.

Optional and Union

A value that may be of any of several types:

from typing import Optional, Union

x: Optional[int]              # Equivalent to: int | None (since 3.10)
y: Union[int, str]            # Equivalent to: int | str (since 3.10)

The int | None and int | str syntax (PEP 604, since 3.10) is the modern form. For older Python, Optional[int] and Union[int, str] remain.

Any

The Any type is the escape hatch:

from typing import Any

def process(x: Any) -> Any:
    return x.whatever()        # type checker accepts anything

Any is compatible with every type; type-checking is effectively suspended for values typed as Any. The conventional discipline is to use Any sparingly and to narrow to a specific type as soon as possible.

Tuples

Tuples may have a fixed shape or be variable-length:

fixed: tuple[int, str, bool]              # exactly three elements
variable: tuple[int, ...]                  # any number of ints
empty: tuple[()]                           # the empty tuple

The ... ellipsis is reserved for the “variable-length of one type” form.

Callables

A callable’s type is Callable[[arg_types], return_type]:

from typing import Callable

handler: Callable[[int, str], bool]
no_args: Callable[[], None]
any_args: Callable[..., Any]               # any signature

The principal use is in higher-order function signatures:

def apply(f: Callable[[int], int], xs: list[int]) -> list[int]:
    return [f(x) for x in xs]

Other built-in shapes

from typing import Iterable, Iterator, Generator, Sequence, Mapping

def first(xs: Iterable[int]) -> int:
    for x in xs:
        return x
    raise ValueError("empty")

def numbers() -> Iterator[int]:
    yield 1
    yield 2
    yield 3

These are abstract container types — Iterable[int] accepts a list, a tuple, a set, a generator, anything that admits iteration. The conventional discipline is to use the most-general type that fits — Iterable[X] for parameters that only iterate, Sequence[X] for indexed/length-known.

TypeVar and generic functions

A generic function takes a type parameter:

from typing import TypeVar

T = TypeVar("T")

def first(xs: list[T]) -> T:
    return xs[0]

n: int = first([1, 2, 3])
s: str = first(["a", "b", "c"])

The T = TypeVar("T") declares a type variable; the function admits any T, and the type checker infers T from the argument type.

Bounded type variables:

from typing import TypeVar
from numbers import Number

N = TypeVar("N", bound=Number)

def doubled(x: N) -> N:
    return x * 2

The bound restricts N to subtypes of Number. Multiple bounds are not directly supported; use Union or TypeVar constraints.

Constrained type variables:

StrOrBytes = TypeVar("StrOrBytes", str, bytes)

def process(x: StrOrBytes) -> StrOrBytes:
    return x[:10]

The constraint admits only the listed types; unlike bound, the variable picks one of the listed types per call.

ParamSpec and Concatenate

For passing a function’s parameter list as a generic, ParamSpec:

from typing import ParamSpec, TypeVar, Callable

P = ParamSpec("P")
R = TypeVar("R")

def trace(f: Callable[P, R]) -> Callable[P, R]:
    def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
        print(f"calling {f.__name__}")
        return f(*args, **kwargs)
    return wrapped

The mechanism admits typing decorators precisely; treated in Decorators.

Generic classes

A class is made generic by inheriting from Generic[T] (older form) or, since Python 3.12, by using the new generic class syntax:

# 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()

s: Stack[int] = Stack()
s.push(42)
n: int = s.pop()

Pre-3.12:

from typing import Generic, TypeVar

T = TypeVar("T")

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()

The new syntax (PEP 695) is shorter and conventional in modern code targeting Python 3.12+.

Protocol for structural typing

The typing.Protocol admits structural typing — “any type with these members”:

from typing import Protocol

class HasLength(Protocol):
    def __len__(self) -> int: ...

def total_length(things: list[HasLength]) -> int:
    return sum(len(t) for t in things)

# Works for str, list, dict, set, ...
total_length(["hi", [1, 2], {"a": 1}])

The treatment is in Duck typing and protocols. The principal point: Protocol admits structural typing where nominal subtyping (inheritance) would be too restrictive.

TypedDict, Final, Literal, LiteralString

Several specialised types:

TypedDict

A dict with specific string keys and per-key value types:

from typing import TypedDict

class Person(TypedDict):
    name: str
    age: int
    email: str | None

p: Person = {"name": "Alice", "age": 30, "email": None}

The mechanism is the conventional way to type a JSON-like dictionary structure where the keys are known at compile time.

The total=False parameter admits optional keys:

class PartialPerson(TypedDict, total=False):
    name: str
    age: int

The Required and NotRequired markers (since 3.11) admit per-field control:

from typing import TypedDict, Required, NotRequired

class Person(TypedDict):
    name: Required[str]
    age: NotRequired[int]

Final

A name that should not be reassigned:

from typing import Final

PI: Final[float] = 3.14159
SETTINGS: Final = {"key": "value"}

The type checker enforces; the runtime ignores.

Literal

A value of a specific literal:

from typing import Literal

def set_log_level(level: Literal["debug", "info", "warning", "error"]) -> None:
    ...

set_log_level("info")          # OK
set_log_level("verbose")        # type error

The mechanism admits enumeration-like type narrowing without defining an Enum.

LiteralString (3.11)

A string that is a literal or composed of literals:

from typing import LiteralString

def execute_query(query: LiteralString) -> Cursor:
    ...

execute_query("SELECT * FROM users WHERE id = 1")    # OK; literal
execute_query("SELECT * FROM users WHERE id = " + user_id)  # type error

The mechanism is the conventional defence against SQL injection at the type-check level.

Type aliases

A name for a type:

from typing import TypeAlias    # Python 3.10+; before, use plain assignment

UserId: TypeAlias = int
Names: TypeAlias = list[str]
Mapping: TypeAlias = dict[str, list[int]]

Python 3.12 introduced the type statement (PEP 695):

type UserId = int
type Names = list[str]
type Result[T] = list[T] | None       # generic alias

The type statement form is the conventional contemporary choice.

cast and assert_type

For cases where the type checker cannot infer the type:

from typing import cast

def find(items: list[Any]) -> Animal:
    result = some_complicated_lookup(items)
    return cast(Animal, result)        # tell the type checker; no runtime check

cast is a no-op at runtime; it tells the type checker to treat the expression as the given type.

typing.assert_type (since 3.11) admits checking that a type is what you think:

from typing import assert_type

x = 5
assert_type(x, int)       # passes; type checker errors if x is not int

The function is a no-op at runtime; the assertion is purely for the type checker.

Type narrowing

The type checker tracks how isinstance, is None, and other guards narrow a type:

from typing import Optional

def process(x: Optional[int]) -> int:
    if x is None:
        return 0
    return x + 1                   # x is int here; the type narrowed

The narrowing rules cover:

  • isinstance(x, T) narrows x to T.
  • is None and is not None narrow.
  • x is True, x is False narrow.
  • assert isinstance(x, T) narrows x thereafter.
  • Custom type guards via typing.TypeGuard (3.10+) or TypeIs (3.13).
from typing import TypeGuard

def is_string_list(xs: list[Any]) -> TypeGuard[list[str]]:
    return all(isinstance(x, str) for x in xs)

def process(xs: list[Any]) -> None:
    if is_string_list(xs):
        # xs is now list[str]
        print(", ".join(xs))

The mechanism admits user-defined type-narrowing predicates.

Forward references

A type that doesn’t exist yet (a class referring to itself, a forward reference):

class Tree:
    def __init__(self, value: int, children: list["Tree"]) -> None:
        self.value = value
        self.children = children

The "Tree" is a string; the type checker resolves it lazily. Without quotes, Tree would not be defined at the time the annotation is evaluated.

The from __future__ import annotations (PEP 563, since 3.7) admits all annotations as strings:

from __future__ import annotations

class Tree:
    def __init__(self, value: int, children: list[Tree]) -> None:
        ...

The annotations are stored as strings and evaluated on demand. The mechanism eliminates the need for explicit forward-reference quoting and reduces import overhead. PEP 649 (deferred evaluation; targeted for Python 3.13/3.14) refines this further.

Type checkers

The principal Python type checkers:

ToolNotes
mypyThe original; created by Jukka Lehtosalo; widely used
pyrightMicrosoft’s type checker; powers Pylance in VSCode; faster
basedpyrightPyright fork with stricter defaults
pytypeGoogle’s type checker
pyreMeta’s type checker

For most projects, mypy or pyright is the conventional choice. They differ in defaults (pyright is stricter by default), speed (pyright is faster), and ecosystem integration (pyright is the basis for VSCode’s Python tooling).

The configuration:

# In pyproject.toml:
[tool.mypy]
strict = true
python_version = "3.12"

[tool.pyright]
strict = ["src/"]

Both tools admit incremental adoption; legacy code remains untyped while new code is annotated.

Common patterns

Type-safe configuration

from typing import TypedDict

class DatabaseConfig(TypedDict):
    host: str
    port: int
    user: str
    password: str

def connect(config: DatabaseConfig) -> Connection:
    ...

The TypedDict admits a JSON-like dict with type-checked keys.

Dependency injection through Protocol

from typing import Protocol

class Logger(Protocol):
    def log(self, message: str) -> None: ...

def process(items: list[str], logger: Logger) -> None:
    for item in items:
        logger.log(f"processing {item}")

Any object with a compatible log method satisfies Logger.

Generic factory

from typing import TypeVar

T = TypeVar("T")

def first_or_default(items: list[T], default: T) -> T:
    return items[0] if items else default

Result type with Union

from dataclasses import dataclass

@dataclass
class Success:
    value: int

@dataclass
class Failure:
    error: str

Result = Success | Failure

def compute() -> Result:
    if condition:
        return Success(42)
    return Failure("oops")

def handle(r: Result) -> int:
    match r:
        case Success(value=v):
            return v
        case Failure(error=e):
            print(e)
            return 0

The pattern combines algebraic data types (via dataclass + Union) with pattern matching for an idiomatic typed result.

Self type

For methods that return self:

from typing import Self     # Python 3.11+

class Builder:
    def with_name(self, name: str) -> Self:
        self.name = name
        return self

Pre-3.11, the conventional pattern uses a TypeVar bound to the class.

A note on the conventional discipline

The contemporary Python type-hint advice:

  • Type hints in new code — they are nearly universal in modern Python projects.
  • Strict mode is preferable — pyright’s strict or mypy’s --strict catches more errors.
  • Use the modern syntaxlist[int] not List[int]; int | None not Optional[int].
  • Test with the type checker in CI — catches errors before review.
  • Use Any sparingly — narrow to specific types as soon as possible.
  • Use Protocol for structural typing — over ABC when subtyping isn’t required.

The combination — gradual typing, modern syntax, mature checkers — admits Python codebases that are nearly as type-safe as statically-typed alternatives. The discipline of writing well-typed Python is a substantial part of fluency in the contemporary language.