Filter concepts by levelShowing all levels.

Python · Type Hints and Static Typing

Core typing

Concepts
7

How to annotate a variable, a collection, an optional or either-of value, and the handful of typing-module names — Any, Literal, Callable, TypeVar, Generic, TypeAlias, Final, Annotated — that appear across most typed Python code.

This section

Annotating values and collections

The basic shape of a type hint, and the built-in container generics.

Basic annotations

corebeginner

A type annotation writes the expected type after a colon — name: type for a variable, param: type for an argument, -> type for a return value. Python stores it but never checks it while running.

Think of it as

An annotation is a label on a shipping box, not a lock on it — the label says "books" so the warehouse (and a type checker) knows what to expect, but nothing stops you physically putting shoes inside. def greet(name: str) -> str: tells a reader and a type checker the shape, while Python itself accepts whatever you pass.

python
age: int = 5

def greet(name: str, times: int = 1) -> str:
    return (name + " ") * times

What we're doing: Annotate a variable and a function's parameters and return value, then show the annotations do not stop the function running with the "wrong" types.

greet.pypython
age: int = 5

def greet(name: str, times: int = 1) -> str:
    return (name + " ") * times

print(greet("Ana", 2))
print(age)
1
age: int = 5 is a variable annotation — int documents the expected type, the = 5 still does the actual assigning.
3
name: str and times: int annotate parameters; -> str annotates what the function returns.
Output
Ana Ana 
5

Why this works: Annotations are metadata Python stores (in __annotations__) and otherwise ignores at runtime — greet("Ana", 2) runs exactly the same whether or not the annotations are present. Their value comes from a separate tool (a type checker) or a human reader, not from the interpreter.

Believing an annotation prevents passing the wrong type

Wrong

python
def greet(name: str) -> str:
    return "Hello, " + name

print(greet(123))  # expected: crashes because 123 isn't a str

Better

python
# it does NOT crash from the annotation — it crashes (or not) from
# what the code actually does with the value:
def greet(name: str) -> str:
    return "Hello, " + name

greet(123)  # TypeError: can only concatenate str (not "int") to str
# — same error you'd get with no annotation at all; the annotation
# played no part in causing or preventing it

What you see: A caller expects TypeError: expected str for the annotation itself, but any error that happens comes from the code's own logic, not from type checking.

Why: Python never inspects a parameter annotation before calling the function. Whatever failure happens is a consequence of the function body running with the actual value it was given — a type checker, run separately, is what would have flagged greet(123) before the code ever ran.

Where an annotation can go

x: int

variable annotation

def f(x: int)

parameter annotation

-> str:

return annotation

  1. x: int — variable annotation
  2. def f(x: int) — parameter annotation
  3. -> str: — return annotation

Remember: name: type / param: type / -> type documents the expected type. Python stores it and never checks it while running.

See also: optional and union types · mypy vs pyright

Generic collection annotations

corebeginner

list[str], dict[str, int], tuple[str, int], and set[str] annotate a collection AND the type of what it holds. Since Python 3.9 these built-ins work directly — no typing.List import needed.

Think of it as

The brackets are a label on the container, not on any one item — dict[str, int] says "keys are str, values are int" the same way a labeled filing cabinet drawer says what belongs inside, without checking every folder as it goes in.

python
names: list[str]
scores: dict[str, int]
point: tuple[str, int]
tags: set[str]

What we're doing: Annotate one of each container generic, and confirm the built-in bracket syntax works without importing anything from typing.

collections.pypython
names: list[str] = ["Ana", "Ben"]
scores: dict[str, int] = {"Ana": 90}
point: tuple[str, int] = ("x", 1)
tags: set[str] = {"a", "b"}

print(names, scores, point, tags)
print(type(names), type(scores), type(point), type(tags))
1
list[str] uses the built-in list directly as a generic — no "from typing import List" needed since Python 3.9.
3
tuple[str, int] is fixed-length: exactly two positions, str then int — not "a tuple of str and int mixed freely."
Output
['Ana', 'Ben'] {'Ana': 90} ('x', 1) {'b', 'a'}
<class 'list'> <class 'dict'> <class 'tuple'> <class 'set'>

Why this works: Since PEP 585 (Python 3.9+), list, dict, tuple, and set support the same subscript syntax typing.List etc. used to require — the runtime types are unchanged, only the annotation spelling got simpler. A type checker reads the brackets to verify what goes in later; Python itself still runs unchanged.

Using tuple[str, int] to mean "any length, mixed str/int" instead of a fixed pair

Wrong

python
def coords() -> tuple[str, int]:
    return ("a", 1, "b", 2)  # four items — quietly wrong per the hint

Better

python
# fixed pair: exactly two positions, first str then int
def coords() -> tuple[str, int]:
    return ("a", 1)

# variable-length, all the same type: use Ellipsis
def many_ints() -> tuple[int, ...]:
    return (1, 2, 3, 4)

What you see: A type checker flags the four-item return as not matching tuple[str, int]; running the code produces no error at all, since Python does not check it.

Why: tuple[str, int] names one position per type: exactly a 2-tuple. tuple[int, ...] (with a literal Ellipsis) is the separate spelling for "any number of int items" — mixing the two meanings up is a common beginner error, and a type checker is what would have caught it, not the interpreter.

Reading a container generic, position by position

scores: dict[str, int] = {"Ana": 90}

dict

the built-in itself — generic-subscriptable directly since PEP 585 — no typing.Dict import

str

key type — every key must be a str

int

value type — every value must be an int

  • Whole: scores: dict[str, int] = {"Ana": 90}
  • dict — the built-in itself: generic-subscriptable directly since PEP 585 — no typing.Dict import
  • str — key type: every key must be a str
  • int — value type: every value must be an int

The four container generics and what each position means

The four container generics and what each position means
AnnotationMeaning
list[str]a list; every element is a str
dict[str, int]a dict; every key is str, every value is int
tuple[str, int]a fixed 2-tuple; position 0 is str, position 1 is int
set[str]a set; every element is a str

Together

python
names: list[str] = ["Ana", "Ben"]
scores: dict[str, int] = {"Ana": 90}
point: tuple[str, int] = ("x", 1)
tags: set[str] = {"a", "b"}

print(names, scores, point, tags)

Remember: list[str], dict[str,int], tuple[str,int], set[str] work directly since Python 3.9 — tuple[str,int] is a FIXED 2-tuple, not "any length."

See also: basic annotations · typevar and generic

Advertisement

Optional values and escape hatches

Expressing "or None"/"or another type," and the tools that loosen or lock down a type.

Optional and Union types

corebeginner

Optional[X] means "X or None." Union[X, Y] means "X or Y." Since Python 3.10, X | None and X | Y are the shorter, preferred spelling for the same thing (PEP 604).

Think of it as

Union is a form field that accepts either an ID card or a passport — either is valid proof, and the code checking it must handle both. Optional[X] is that same field with "or leave blank" added — X | None is just the modern way to write "X, or nothing."

python
from typing import Optional, Union

def find_user(uid: int) -> Optional[str]: ...      # str or None
def parse(value: Union[int, str]) -> str: ...       # int or str

# modern spelling (Python 3.10+), same meaning:
def find_user2(uid: int) -> str | None: ...
def parse2(value: int | str) -> str: ...

What we're doing: Write a lookup function that returns None when nothing is found, using both the typing.Optional spelling and the modern X | None spelling.

lookup.pypython
from typing import Optional

def find_user(uid: int) -> Optional[str]:
    return None if uid < 0 else f"user-{uid}"

print(find_user(-1))
print(find_user(5))


# PEP 604 spelling — same meaning, no import needed
def find_user2(uid: int) -> str | None:
    return None if uid < 0 else f"user-{uid}"

print(find_user2(-1))
3
-> Optional[str] tells a type checker this can return a str OR None — never anything else.
10
str | None is the PEP 604 spelling of the same thing, available without importing Optional.
Output
None
user-5
None

Why this works: Both spellings tell a type checker (not the runtime) that None is a legitimate return value here, so any caller's type checker will flag code that uses the result without first checking for None. Python itself runs both versions identically — the difference is purely which import is needed.

Reading Optional[str] as "this parameter is optional to pass"

Wrong

python
from typing import Optional

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

greet()  # TypeError: missing required argument — Optional did NOT make it skippable

Better

python
from typing import Optional

# a DEFAULT VALUE is what makes an argument skippable, not Optional:
def greet(name: Optional[str] = None) -> str:
    return f"Hello, {name or 'stranger'}"

greet()  # works — the = None default is what allows omitting it

What you see: TypeError: greet() missing 1 required positional argument: 'name' — even though the parameter is annotated Optional.

Why: Optional[str] only describes the set of acceptable VALUES (str or None) — it says nothing about whether the argument can be left out of the call. Only a default value (= None) makes a parameter skippable; the two are unrelated and easy to conflate by name alone.

get_user returns a User, or nothing

get_user(id)

looks up the id

User | None

found -> User, missing -> None

caller checks

must handle both branches

  1. get_user(id) — looks up the id
  2. User | None — found -> User, missing -> None
  3. caller checks — must handle both branches

Remember: Optional[X] = X | None. Union[X, Y] = X | Y. Optional never means "the argument can be omitted" — a default value does that.

See also: basic annotations · type narrowing and guards

Any, Literal, and Final

standardintermediate

Any turns off type checking for a value entirely. Literal["r", "w"] restricts a value to specific literal options. Final marks a name as meant to never be reassigned. None of the three are enforced while the code runs.

Think of it as

Any is a checkpoint waving every car through unchecked; Literal is a checkpoint that only lets three named license plates pass; Final is a sign reading "do not move this" that nobody is physically stopped from ignoring — a type checker reads the sign, Python does not.

python
from typing import Any, Literal, Final

def handle(payload: Any) -> None: ...        # anything goes, unchecked

Mode = Literal["r", "w", "a"]
def open_file(path: str, mode: Mode) -> str: ...

MAX_RETRIES: Final = 3                        # not meant to change

What we're doing: Use Any for an untyped payload, Literal to restrict a mode argument to three options, and Final for a constant — then show none of the three actually stop the "wrong" thing happening at runtime.

any_literal_final.pypython
from typing import Any, Literal, Final

def handle(payload: Any) -> None:
    print("handled:", payload)

Mode = Literal["r", "w", "a"]
def open_file(path: str, mode: Mode) -> str:
    return f"{path} opened in {mode}"

MAX_RETRIES: Final = 3
print(open_file("f.txt", "r"))
handle({"k": 1})
print(MAX_RETRIES)

MAX_RETRIES = 200  # violates Final — Python runs this without error anyway
print("Final reassigned at runtime without error:", MAX_RETRIES)
3
payload: Any means a type checker will not flag ANY value passed here — it opts the parameter out of checking.
6
Mode = Literal["r", "w", "a"] restricts mode to exactly those three strings, as far as a type checker is concerned.
15
Reassigning MAX_RETRIES violates Final, but Python has no runtime concept of Final — this line runs with no error.
Output
f.txt opened in r
handled: {'k': 1}
3
Final reassigned at runtime without error: 200

Why this works: All three annotations are read only by a type checker. Any tells it to stop checking; Literal gives it a closed set of acceptable values to check against; Final tells it to flag any later reassignment. Python's interpreter has no equivalent runtime concept for any of the three, so violating them produces no exception — only a static type checker run separately would catch it.

Reaching for Any to silence a type error instead of fixing the actual type

Wrong

python
from typing import Any

def process(data: Any) -> Any:
    return data.upper()  # no checker warning, but data might not have .upper()

Better

python
def process(data: str) -> str:
    return data.upper()  # a real type checker now verifies every call site

What you see: A bug that a specific type hint would have caught (calling .upper() on a non-str) ships silently, because Any told the type checker to stop looking.

Why: Any is not "I don't know the type" — it is "stop checking this value," which also silences every check on anything derived from it. Reaching for the actual expected type (or a Union of a few specific types) keeps the type checker's guarantees; Any should be a deliberate, rare escape hatch, not a default fix for a type error.

Remember: Any disables checking; Literal restricts to specific values; Final marks "should not change." A type checker enforces all three — Python itself enforces none.

See also: optional and union types · mypy vs pyright

Advertisement

Functions and generic placeholders

Annotating a callback, and naming a placeholder type shared across a signature or class.

Callable

standardintermediate

Callable[[ArgTypes], ReturnType] annotates a value that is itself a function — a callback parameter, for example. Callable[[int, int], int] means "a function taking two ints, returning an int."

Think of it as

Callable is a job posting describing the shape of the work, not the worker — Callable[[int, int], int] says "hand me a function that takes two ints and gives back an int," and any lambda, named function, or callable object matching that shape qualifies.

python
from typing import Callable

def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
    return fn(a, b)

What we're doing: Accept a callback function as a parameter, annotated with the exact signature it must have.

apply.pypython
from typing import Callable

def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
    return fn(a, b)

print(apply(lambda a, b: a + b, 2, 3))
3
Callable[[int, int], int] says fn must take two ints and return an int — the [int, int] list is the argument types, int after the comma is the return type.
Output
5

Why this works: apply(lambda a, b: a + b, 2, 3) works because the lambda's actual shape (two positional arguments, returns their sum) matches the Callable[[int, int], int] hint. A type checker verifies that match statically; at runtime, Python just calls fn(a, b) and trusts it works.

Annotating a callback's return type as its own type instead of Callable

Wrong

python
def apply(fn: int, a: int, b: int) -> int:   # wrong: fn is a function, not an int
    return fn(a, b)

Better

python
from typing import Callable

def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
    return fn(a, b)

What you see: A type checker flags fn: int as incompatible the moment fn(a, b) is called on it, since int is not callable at all.

Why: The parameter's own annotation should describe what fn IS (a function with a given signature), not what it eventually returns — Callable[[ArgTypes], ReturnType] is the type of "a function," distinct from the type its call produces.

Remember: Callable[[int, int], int] means "a function taking two ints, returning an int." A type checker verifies the match; Python just calls it.

See also: basic annotations · typevar and generic

TypeVar and Generic

standardintermediate

TypeVar("T") creates a placeholder type name. Generic[T] lets a class be parametrized over it, so Box[int] and Box[str] are both Box but each remembers what it holds.

Think of it as

TypeVar is a fill-in-the-blank on a form, not a fixed value — 'T' stands for whatever concrete type shows up at each specific use. Box(Generic[T]) is a reusable box shape; Box[int] and Box[str] are the same shape stamped with different labels, so a type checker knows exactly what came out when you open one.

python
from typing import TypeVar, Generic

T = TypeVar("T")

class Box(Generic[T]):
    def __init__(self, item: T) -> None:
        self.item = item
    def get(self) -> T:
        return self.item

What we're doing: Define a Box that can hold any one type, and show a type checker would track exactly which type a given Box instance holds.

box.pypython
from typing import TypeVar, Generic

T = TypeVar("T")

class Box(Generic[T]):
    def __init__(self, item: T) -> None:
        self.item = item
    def get(self) -> T:
        return self.item

b: Box[int] = Box(5)
print(b.get(), type(b.get()))
3
T = TypeVar("T") declares a placeholder — it is not a real type yet, just a name to reuse consistently.
5
Generic[T] makes Box parametrizable over T — every T in the class body refers to the same placeholder.
11
Box[int] fills T with int for this instance, so a type checker knows b.get() returns an int, not "whatever."
Output
5 <class 'int'>

Why this works: Generic[T] lets Box's methods reference the same placeholder T consistently — item: T in __init__ and the return type of get() are tied together, so a type checker can verify that whatever type goes into Box(...) is exactly what get() gives back. Python itself runs unaffected; Box(5) works the same with or without the annotations.

Reusing the same TypeVar across unrelated generic classes, expecting them to share a constraint

Wrong

python
from typing import TypeVar, Generic

T = TypeVar("T")

class Box(Generic[T]): ...
class Bucket(Generic[T]): ...

# expecting Box[int] to somehow constrain what Bucket[T] can hold — it doesn't

Better

python
# each class's use of T is independent — sharing the TypeVar OBJECT
# doesn't link the classes together at all; this is fine as written,
# just don't expect cross-class constraints from it:
box: Box[int] = Box()
bucket: Bucket[str] = Bucket()  # completely unrelated to box's int

What you see: No error at all — the confusion is purely a wrong mental model, not a bug the interpreter or a type checker would catch.

Why: A TypeVar only ties together the occurrences of T within ONE generic class or function signature — reusing the same TypeVar object across different classes does not link those classes' type parameters to each other in any way.

One shape, two concrete fillings

Box(Generic[T])

the reusable shape

Box[int]

T filled with int

Box[str]

T filled with str

  1. Box(Generic[T]) — the reusable shape
  2. Box[int] — T filled with int
  3. Box[str] — T filled with str

Remember: TypeVar("T") is a placeholder type; Generic[T] lets a class be parametrized over it — Box[int] and Box[str] are tracked as distinct.

See also: generic collection annotations · writing generic classes and functions · variance

Advertisement

Naming a type and attaching metadata

Giving a type a clearer name for reuse, and attaching extra information to one.

TypeAlias and Annotated

standardintermediate

UserId: TypeAlias = int gives int a second, more meaningful name for use in annotations. Annotated[int, "must be > 0"] attaches extra metadata to a type without changing what the type itself means.

Think of it as

A type alias is a nickname on a door, not a new room — UserId and int are the exact same type underneath, the alias only makes a signature easier to read. Annotated is a sticky note on that door: the metadata rides along for a tool that wants to read it, but the door itself (the real type) doesn't change.

python
from typing import TypeAlias, Annotated

UserId: TypeAlias = int

def lookup(uid: UserId) -> str: ...

PositiveInt = Annotated[int, "must be > 0"]
def set_age(age: PositiveInt) -> None: ...

What we're doing: Give int a meaningful alias for use in a signature, and attach descriptive metadata to a type with Annotated.

aliases.pypython
from typing import TypeAlias, Annotated

UserId: TypeAlias = int

def lookup(uid: UserId) -> str:
    return f"user-{uid}"

PositiveInt = Annotated[int, "must be > 0"]
def set_age(age: PositiveInt) -> None:
    print("age set to", age)

print(lookup(7))
set_age(30)
3
UserId: TypeAlias = int makes UserId a documented synonym for int — nothing new at runtime.
8
Annotated[int, "must be > 0"] is still an int to a type checker; the string is metadata a tool can choose to read.
Output
user-7
age set to 30

Why this works: lookup(7) and set_age(30) both run exactly as if UserId and PositiveInt were spelled int directly — TypeAlias and Annotated are read-time information for a type checker or a framework, not runtime types Python enforces. The value of both is purely making a signature clearer or carrying extra structured information for whichever tool wants it.

Expecting Annotated's metadata to be enforced just because it is present

Wrong

python
from typing import Annotated

PositiveInt = Annotated[int, "must be > 0"]

def set_age(age: PositiveInt) -> None:
    print("age set to", age)

set_age(-5)  # "must be > 0" is just a string — nothing stops this

Better

python
def set_age(age: int) -> None:
    if age <= 0:
        raise ValueError(f"age must be > 0, got {age}")
    print("age set to", age)

# Annotated is useful for a framework (Pydantic, FastAPI) that is
# specifically written to read and enforce its metadata — plain
# Python and a generic type checker do not

What you see: set_age(-5) runs and prints "age set to -5" with no error at all, despite the "must be > 0" metadata sitting right there in the annotation.

Why: Annotated only carries metadata for whatever tool chooses to read it — Python itself ignores it completely, and even mypy/pyright treat Annotated[int, ...] as plain int for type-checking purposes. Actual validation still needs real code (an if check) or a framework built specifically to interpret that metadata.

Remember: TypeAlias names a type for reuse; Annotated attaches metadata a tool can read. Neither is enforced by Python or by most type checkers alone.

See also: basic annotations · typed dict

Advertisement