Index
Contents
Chapter 8

Static Types

A value has a type whether or not you write it down, and Python reports a mistake about it when the operation runs. Static types let a tool report that mistake before the program runs.

C++ and Java require type declarations, and they check those types during compilation. The Python runtime checks types only when an operation runs. The examples so far have used few type declarations. On a small program you may not miss them.

Python 3.0 let you annotate parameters and return values (PEP 3107) but left the meaning of those annotations to third-party libraries. Python 3.5 (2015) introduced type hints (PEP 484), which look like the type declarations of statically typed languages. The Python runtime never acts on a type hint. It stores the hint and evaluates it only when code reads the annotations, through __annotations__ or annotationlib.get_annotations().

Older code often begins with a __future__ import of annotations (PEP 563), an earlier form of postponed evaluation that stores each annotation as a string. Deferred evaluation, the default since Python 3.14 (PEP 649), supersedes it, so the book’s listings leave that import out.

If you want static type checking like you get from a compiler in a typed language, you must run a separate type-checking tool. Mypy is the original and most widely deployed one. Pyright is the one most editors run. This book uses Astral’s ty, from the same group that makes uv and ruff.

Pyright checks the same listings as a second opinion. The book’s build relies on ty’s verdict alone. Where Pyright or mypy disagrees with ty on a listing, the text says so and names the checker, because a verdict on a hard case is a fact about one checker, not about Python.

Gradual Typing

You can add type hints one function at a time. Code without hints still works. The type checker treats each missing annotation as the type Any, which is compatible with everything. Thus, typed and untyped code can coexist, and that coexistence is gradual typing. You can slowly add hints where they help most: the public interfaces, the tricky data, the code on which other people depend.

An Any you write marks a value as dynamic. ty reports the Any that comes from a missing annotation as Unknown, to distinguish it from an Any you wrote yourself. The two behave the same. Each is compatible with everything.

Any and object differ, though both accept every value. object is the base of every class, so an object value carries the methods every class shares. The type checker rejects every operation beyond those methods. Any permits every operation, so it switches the checker off for that value rather than describing a wide set of values. Choose object for a value you store or pass along untouched, and Any for one whose operations you would rather check yourself.

Type Hints

A hint annotates a parameter, a return value, or a variable. Use a colon for parameters and variables, and an arrow for the return type:

# typed_basics.py

def repeat(text: str, times: int) -> str:
    return text * times

print(repeat("ab", 3))
#: ababab

total: int = 0
for word in ["a", "bb", "ccc"]:
    total += len(word)

print(total)
#: 6

Containers and optional types read the way you say them: list[int], dict[str, float], tuple[int, ...], and str | None for “a string or nothing.” A tuple annotation specifies one type per position, so tuple[int, str] is a pair and tuple[int] is a tuple of one int. The ... in tuple[int, ...] means zero or more ints.

A function declares -> None when it returns None, by a return with no value or by reaching its end, so every __init__() in this chapter’s listings carries that annotation.

The Type Checker: ty

Python runs the code the same with or without the hints. You need a tool to check them:

ty check

uvx ty check runs it in a temporary, isolated environment, and uv tool install ty@latest puts it on your path.

ty reports an error where the hints and the code disagree, and stays silent when they agree. The build runs ty on every change, so the code you read here checks as well as runs.

Catching Mistakes

Type checking discovers mistakes before the program runs.

# area.py
def area(width: int, height: int) -> int:
    return width * height

# ty: argument of type "str" is not assignable to "int":
print(area("3", 4))  # type: ignore
#: 3333

At runtime area("3", 4) succeeds. It returns "3333", because "3" * 4 repeats the string four times. The wrong value causes a failure later, often far from the line that produced it. The type checker reports the str argument at the call, before the program runs.

The # type: ignore comment tells the type checker to skip this line, so this book’s build passes. Without it, ty check reports:

error[invalid-argument-type]: Argument to function `area` is incorrect
 --> area.py:6:12
  |
6 | print(area("3", 4))
  |            ^^^ Expected `int`, found `Literal["3"]`
info: Function defined here
 --> area.py:2:5
  |
2 | def area(width: int, height: int) -> int:
  |     ^^^^ ---------- Parameter declared here

A diagnostic names the rule in brackets, points at the offending line, pairs what the annotation expected with what the call supplied, and then points at the declaration that set the expectation.

Listings in this book use a shorthand for a diagnostic. A neighboring # ty: comment summarizes what the type checker reports for a line. For a line that fails the check, commented out or suppressed with # type: ignore, the comment gives the diagnostic. For a reveal_type() call, the comment gives the type the call produces. reveal_type(), imported from typing, marks an expression whose inferred type you want to see, and ty check prints that type as a diagnostic.

The # ty: summary is for you, not for the tool. ty acts on three comment forms. # type: ignore and a bare # ty: ignore silence every diagnostic on their line, and # ty: ignore[invalid-argument-type] silences the one rule it names. # type: ignore is the form every type checker honors.

Narrowing

A union type covers every case until you rule some out. Testing is not None on an X | None value proves to the type checker, as well as to you, that the value is an X:

# narrowing.py

def shout(text: str | None) -> str:
    if text is not None:
        return text.upper()
    return "(nothing)"

print(shout("hi"))
#: HI
print(shout(None))
#: (nothing)

Inside the if, the type checker narrows text from str | None to str, so the type checker accepts .upper() as written. After the if, text is None, since the branch returned for every str. If the branch falls through, text is the full str | None again.

The same narrowing follows an isinstance() check, an equality test, or an identity test against a specific value such as is not SOME_SENTINEL. A truthiness test, if text:, also narrows text to str, since None is falsy. But the empty string is falsy too, so if text: sends "" to the other branch along with None. Test is not None when an empty value counts as supplied (see exercise 8).

Narrowing also makes an object parameter usable. Gradual Typing says the type checker rejects every operation on an object beyond the methods every class shares, so a function needs an isinstance() test before it can do more with the value:

# object_narrowing.py

def describe(value: object) -> str:
    # ty: "object" has no attribute "upper":
    # return value.upper()
    if isinstance(value, str):
        return value.upper()
    return repr(value)  # [1]

print(describe("hi"))
#: HI
print(describe(42))
#: 42

repr() accepts every object, so the return at [1] handles any value that reaches it. If you annotate value as Any and uncomment the return above the if, the type checker accepts it, and describe(42) fails at runtime with an AttributeError.

Narrowing an attribute is riskier than narrowing a local variable. A call between the check and the use can reset that attribute. The type checker assumes the call leaves the attribute as it was:

# narrowing_attribute.py
from exceptions import expect

class Box:
    def __init__(self, val: str | None) -> None:
        self.val = val

    def reset(self) -> None:
        self.val = None

def show(b: Box) -> str:
    if b.val is not None:
        b.reset()  # ty can't see this clears val
        return b.val.upper()
    return "(nothing)"

expect(AttributeError, show, Box("hi"))
#: [AttributeError] 'NoneType' object has no attribute
#: 'upper'

ty check passes this file. The if narrows b.val to str, and the checker keeps that narrowing through the return, because the checker does not track which attributes reset() assigns. The AttributeError shows that b.val was None by the time upper() ran, while the checker still treated it as a str.

A narrowing on a local variable holds. A narrowing on an attribute holds until a call rebinds the attribute, so recheck the attribute after any call that might change the object. Copying the attribute into a local variable before the test keeps the narrowing valid, because a function defined elsewhere cannot rebind its caller’s local variable, short of stack-frame introspection (see exercise 9).

Constants with Final

Final on a name makes the type checker report an accidental reassignment.

The naming convention in Tour uses ALL_CAPS to signal a constant, but that is only a hint to human readers. At runtime, a Final name is an ordinary variable. Only the type checker rejects a reassignment:

# final_constants.py
from typing import Final

MAX_RETRIES: Final = 3
GREETING: Final[str] = "hello"
HISTORY: Final[list[str]] = []

# ty: cannot assign to final name "MAX_RETRIES":
# MAX_RETRIES = 5

HISTORY.append("first")
print(MAX_RETRIES, GREETING, HISTORY)
#: 3 hello ['first']

The word Final suggests immutability, but the qualifier blocks rebinding the name, not mutation of the object the name holds. HISTORY.append("first") checks and runs, the same as it does on any list. For a collection that must stay as it is, hold a tuple or a frozenset under the Final name, since neither type has a method that changes its contents (see exercise 10).

You can write the type in the annotation, as in GREETING, or let the type checker infer it from the value, as with MAX_RETRIES. The two forms differ when the initializer says less than you mean. CACHE: Final = [] infers list[Unknown], so the type checker accepts any value you put into the list. CACHE: Final[list[str]] = [] says what the list holds, so the type checker enforces list[str]. Every other listing in this book writes the type out on Final.

Structural Typing with Protocols

Earlier chapters rely on dynamic typing. A function accepts any object, so long as the object supports the operations the function performs on it. Python checks the type at runtime, when the operation runs. Programmers often call dynamic typing duck typing. If it looks like a duck and quacks like a duck, treat it as a duck.

Structural typing is the static counterpart. A type checker verifies ahead of time that an object has the required shape. “Shape” means the methods and attributes that the code using the object requires. Dynamic typing and structural typing are the same idea checked at different moments. Dynamic typing finds a missing method when the call runs, while structural typing reports it before the program runs.

Some statically typed languages make you declare up front that a class “is a” Drawable by inheriting from it. A Protocol describes a required shape. Any object with that shape qualifies, whatever its base classes:

# protocols.py
from typing import Protocol

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

class Circle:
    def draw(self) -> str:
        return "circle"

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

def render(shape: Drawable) -> str:
    return shape.draw()

class Blob:
    def paint(self) -> str:
        return "blob"

print(render(Circle()))
#: circle
print(render(Square()))
#: square
# ty: expected "Drawable", found "Blob":
# render(Blob())

Neither Circle nor Square mentions Drawable. The type checker accepts both because each has a draw(self) -> str, so each matches Drawable’s shape. The signature is part of that shape. A draw() that returns an int, or that requires an argument, gives its class a different shape. The type checker rejects that class where an annotation specifies Drawable.

Blob draws, in the everyday sense, but its method’s name is paint(). A protocol matches on names and signatures rather than on intent. A protocol accepts any class with the right methods, as dynamic typing does, and reports a mismatch before the program runs, as static type checking does.

A Protocol is a checking-time construct, so isinstance(Circle(), Drawable) raises a TypeError instead of returning True or False. Decorating the Protocol with @runtime_checkable allows the call, but isinstance() then makes a weaker check. It confirms that each method exists and ignores its signature.

Classes as Values: type[C]

A class is also a value, so you can pass it to a function, store it in a variable, and call it to make an instance. An annotation needs a way to distinguish the class from an instance of that class.

A plain SomeType annotation means an instance of SomeType. The form type[SomeType] means the class object, or any subclass of it:

# class_values.py
from typing import reveal_type

class Shape:
    pass

class Circle(Shape):
    pass

def make(kind: type[Shape]) -> Shape:
    return kind()

shape = make(Circle)
reveal_type(shape)  # ty: Shape
print(type(shape).__name__)
#: Circle

Passing Circle works because Circle is a subclass of Shape.

The word type plays two roles in this listing: the annotation type[Shape] names the class to the type checker, while the built-in call type(shape) in the demo retrieves an object’s class at runtime. The type checker and the runtime give different answers for shape. reveal_type(shape) reports Shape, because the type checker reads make()’s -> Shape return annotation, while type(shape) finds the Circle that kind() built.

A type[...] parameter accepts a class. It rejects a type expression such as int | None, which can appear in an annotation but is a union rather than a class, so passing it draws invalid-argument-type. A function that validates or converts data against an arbitrary annotation takes a type expression as its argument. Python 3.15 (PEP 747) adds TypeForm[T] to annotate that parameter. Given def convert[T](form: TypeForm[T], raw: object) -> T, the call convert(int | None, 3) has the type int | None.

Naming Types: The type Statement

An annotation can grow to the point of obscurity. dict[tuple[int, int], str] is precise, but it leaves the reader to guess what those pairs and strings represent. The type statement gives the annotation a name:

# type_aliases.py
from typing import Literal

type Coord = tuple[int, int]
type Color = Literal["red", "blue", "green", "yellow"]
type Grid = dict[Coord, Color]

def paint(grid: Grid, cell: Coord, color: Color) -> None:
    grid[cell] = color

grid: Grid = {}
paint(grid, (2, 3), "red")
print(grid)
#: {(2, 3): 'red'}
print(type(grid).__name__)
#: dict

Like match, type is a soft keyword. It is a keyword only at the start of this statement. Everywhere else, type is still the built-in type() function, so the listing’s type(grid) call returns dict as it always has.

A type statement creates a new name, not a new type. Coord and tuple[int, int] are interchangeable, so the type checker accepts any pair of ints as a Coord. type UserId = int reads like a distinct type in a signature, yet the type checker accepts any int as a UserId. (For a type the type checker keeps separate from its base, use NewType.)

Because an alias is the same type under another name, the type checker sees through it. Its job is to tell the reader what a value is. A compound shape is the usual case, but a scalar gets an alias when its name says what a bare str or int cannot: dict[Name, int] says the keys are names, where dict[str, int] hides what they are. Skip the alias when the parameter’s own name says it, as name: str does.

Color specifies a union of literal values instead of a union of types. Literal["red", "blue", "green", "yellow"] restricts the parameter to those four strings. Passing "purple" to paint() is a type error, even though "purple" is a valid str. The alias also documents the allowed values in one place, instead of scattering the literal list across every function that accepts a Color.

A Literal union closes a set of values without defining a class. If those values need behavior or an identity of their own, an Enum is the better fit. Data Classes as Types makes the case for an Enum whenever the set of values is small and closed, then shows when an Enum beats a data class.

An alias can also name a union of types. Pattern Matching uses type Shape = Circle | Square to define a closed set of alternatives that a match can check exhaustively.

Generic Functions and Classes

Type Parameters

Consider a function that returns the first element of a list holding any type. A useful annotation makes the return type match the list’s element type, whatever that type is.

Any loses that connection. first_any() accepts any list, but its return type is Any whatever the list holds:

# first_any.py
from typing import Any
from exceptions import expected

def first_any(items: list) -> Any:
    return items[0]

n = first_any([10, 20, 30])
with expected(AttributeError):
    n.upper()
#: [AttributeError] 'int' object has no attribute 'upper'

ty check passes this file. n is Any, so every attribute access on it type-checks, including one that runs and fails.

expected() is the with form of the expect() helper that Functions introduces. The block under with expected(AttributeError): runs, and expected() catches the named exception and prints it. Context Managers builds expected().

A type parameter expresses the connection. Declare the parameter in square brackets after the function name:

# generics.py

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

n = first([10, 20, 30])  # T is int
print(n + 1)
#: 11
# ty: "int" has no attribute "upper":
# n.upper()
s = first(["a", "b"])  # T is str
print(s.upper())
#: A

T is a placeholder, filled in separately at each call. The type checker infers T from the argument and substitutes it into the return type. In first_any.py the same n.upper() call type-checks and then fails at runtime.

A class declares type parameters in square brackets after the class name:

# generic_box.py

class Box[T]:
    def __init__(self, content: T) -> None:
        self.content = content

    def get(self) -> T:
        return self.content

box = Box("gift")  # A Box[str]
print(box.get().upper())
#: GIFT

A bound limits the parameter to one hierarchy. class Box[T: Shape] accepts Shape and its subclasses. T keeps the subclass the caller passes. A constraint lists unrelated choices. With [T: (int, str)], T is int or str. An argument whose class subclasses int binds T to int, where a bound keeps the subclass.

Variance

A list[Circle] is not a list[Shape], and that surprises most people the first time:

# variance.py
from collections.abc import Sequence

class Shape:
    pass

class Circle(Shape):
    pass

class Square(Shape):
    pass

def count(shapes: Sequence[Shape]) -> int:
    return len(shapes)

def add_square(shapes: list[Shape]) -> None:
    shapes.append(Square())

circles: list[Circle] = [Circle(), Circle()]
print(count(circles))
#: 2
# ty: expected "list[Shape]", found "list[Circle]":
# add_square(circles)

A list accepts writes. If you pass circles, add_square() appends a Square to a list declared as list[Circle]. The type checker refuses the call, so the declaration list[Circle] stays true. A read-only container is safe to pass this way, so Sequence[Shape] accepts a list[Circle]. A Sequence[Shape] parameter declares that the function reads its argument and leaves it as it was. That declaration lets the function accept arguments that a list[Shape] parameter rejects. A list[T] is invariant in T, and a Sequence[T] is covariant.

Type Parameter Defaults

A type parameter can carry a default, and the type checker uses it when an annotation specifies the class without its brackets:

# type_defaults.py
from typing import reveal_type

class Stack[T = str]:
    def __init__(self) -> None:
        self.items: list[T] = []

    def push(self, item: T) -> None:
        self.items.append(item)

    def top(self) -> T:
        return self.items[-1]

words: Stack = Stack()  # No brackets, so T is str
words.push("beta")
reveal_type(words.top())  # ty: str
print(words.top().upper())
#: BETA
counts: Stack[int] = Stack()
counts.push(2)
print(counts.top() + 1)
#: 3

A default matters most for a class whose type parameter is usually the same type. Callers who use that type omit the brackets and still get a precise annotation.

Without the default, the annotation words: Stack leaves T unsolved (see exercise 5).

A type alias declares a default with = inside its brackets, as Pair shows:

# alias_default.py

type Pair[T = int] = tuple[T, T]

def is_origin(point: Pair) -> bool:  # Pair means Pair[int]
    return point == (0, 0)

print(is_origin((0, 0)))
#: True

Defaulted parameters go last, the way defaulted function parameters do, so class Table[K = str, V] is a syntax error. Python 3.13 added type parameter defaults, one release after the bracket syntax.

**P and the Older TypeVar Syntax

A special form, **P, captures the types of an entire parameter list. Decorators uses **P to give a wrapper the same signature as the function it wraps.

Before Python 3.12 you wrote type parameters with TypeVar and Generic, which you still see in older code. The older form creates each type parameter as a module-level TypeVar object and lists Generic[T] among a generic class’s bases:

from typing import Generic, TypeVar

T = TypeVar("T")

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

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

These first() and Box mean the same as the bracket versions in generics.py and generic_box.py. The bracket form declares T in the function or class header, so T exists only inside that function or class.

The Self Return Type

A method that returns its own instance allows call chaining. What should the type hint be? Specifying the enclosing class works until someone inherits from it. Self means “an instance of the class on which you called this method,” so in a subclass it means the subclass:

# self_type.py
from typing import Self

class Tally:
    def __init__(self) -> None:
        self.count = 0

    def bump(self) -> Self:
        self.count += 1
        return self

class NamedTally(Tally):
    def __init__(self, name: str) -> None:
        super().__init__()
        self.name = name

    def report(self) -> str:
        return f"{self.name}: {self.count}"

t = NamedTally("clicks")
print(t.bump().bump().report())
#: clicks: 2

t.bump() runs on a NamedTally, so Self is NamedTally, and the result has report(). If bump() declares -> Tally, the type checker rejects report(), because Tally has no such method.

Alternative constructors need Self for the same reason bump() does. A @classmethod that ends with return cls(...) declares -> Self, so a call on a subclass has the subclass’s type, not the base’s type.

Typing **kwargs with a TypedDict

A **kwargs parameter collects keyword arguments into a dictionary. Annotating it as **kwargs: int says that every value is an int, but it leaves open which keywords a caller may pass. A TypedDict describes a dictionary with specific keys, each with its own value type. Since Python 3.12 (PEP 692), Unpack[...] applies that description to **kwargs, so the type checker treats each key as a named, typed keyword parameter:

# typed_kwargs.py
from typing import NotRequired, TypedDict, Unpack

class Style(TypedDict):
    width: int
    fill: NotRequired[str]

def label(text: str, **style: Unpack[Style]) -> str:
    fill = style.get("fill", " ")
    return f"[{text.center(style['width'], fill)}]"

print(label("ok", width=6))
#: [  ok  ]
print(label("ok", width=6, fill="*"))
#: [**ok**]

Inside label(), style is a Style dictionary. Style requires width, so label() reads style['width']. NotRequired makes fill optional, so label() reads it with .get() and a default.

ReadOnly, added in Python 3.13 (PEP 705), is another per-key qualifier. The type checker accepts a read of a ReadOnly key and rejects an assignment to it. If Style declares width: ReadOnly[int], style['width'] = 8 inside label() draws invalid-assignment, with the message that the key “is marked read-only.” At runtime the assignment succeeds, since every TypedDict value is a dict.

The type checker verifies the keywords at each call. A call that passes width="6" draws invalid-argument-type from ty, the same diagnostic a wrongly typed ordinary parameter draws. The diagnostic’s message reads “Expected int, found Literal["6"]”. A call that leaves out width draws missing-argument.

The checkers disagree about a misspelled keyword. Pyright rejects fill misspelled as fil="*" with No parameter named "fil", but ty 0.0.95 accepts that call silently. At runtime fil becomes an extra key in style. label() ignores that key, so the fill stays a space.

A TypedDict is open by default. It lists the keys a dictionary must have, so a TypedDict with more keys still satisfies it. Python 3.15 (PEP 728) adds closed TypedDicts, which permit only their declared keys. Declaring class Style(TypedDict, closed=True): makes ty reject fil="*" with unknown-argument. Writing extra_items=str instead of closed=True admits undeclared keys and requires a str for each of their values.

A TypedDict helps most when several functions accept the same options. One TypedDict declares them once, so every signature that unpacks it accepts the same keywords.

Hints Are Not Enforced at Run Time

Type hints do not change what the program does. Python stores them and otherwise ignores them. A wrongly typed value that gets past the type checker runs as it does in a program with no hints. Checking is a separate step you run, the same way you run tests. The hints are for the tools and for the reader.

If you need a runtime guarantee, use isinstance() or a library that validates data. The typeguard library reads your existing annotations and enforces them at runtime. Pydantic validates and parses data against typed models. Pydantic suits the edges of a program, where untrusted input enters.

From here on, this book assumes the type checker runs on everything. When a listing says the type checker rejects a line, that rejection is the only enforcement.

How Much to Annotate

Gradual typing leaves the amount up to you, so the question is when a hint is worth the words. Annotate what crosses a boundary: function signatures, public attributes, anything another file imports. Those are the places where the code’s reader and writer are different people, and where a wrong assumption and the failure it causes sit farthest apart.

Let the type checker infer the rest. For a local variable whose type is obvious from its initializer, a hint repeats what the initializer says. count: int = 0 says the same as count = 0, at greater length. (The total: int = 0 in this chapter’s first listing shows the syntax, not a recommendation.)

A hint helps in proportion to the distance between a value’s creation and its use. A value created and used within three lines can leave its type to inference. A value that arrives from another module, inside a container, needs a precise annotation.

Type Hint Summary

These are the type hints you encounter, in their modern forms. Each subsection opens with a link to the associated Python documentation. Thinking in Types explores types in more depth.

Type hints go in three places: a parameter (x: int), a return value (-> str), and a variable or attribute (total: int = 0). Most of the names below come from the typing module. The abstract container types come from collections.abc.

Basic Types

Python documentation: basic types

Construct Meaning
int, str, float, bool, bytes, complex The built-in types, annotated by name alone, with no type parameter; the type checker accepts an int where a declaration says float, and an int or float where it says complex, but not the reverse (bytes and complex are not used as annotations elsewhere in this book)
None The value None; the return type of a function that returns nothing
object Any object, with the methods every class shares and no others (safer than Any)
Any Opts out of checking; compatible with every type, see Gradual Typing
Never, NoReturn The “impossible” type, which no value has; NoReturn marks a function that never returns (it always raises an exception or exits), and Never is the same type under a name that also suits other positions
LiteralString A str built only from literals, for injection-sensitive APIs

Containers

Python documentation: containers

Construct Meaning
list[T], set[T] A homogeneous collection of T; invariant, so list[Circle] is not a list[Shape], see Variance
frozenset[T] An immutable homogeneous set of T; covariant, because it has no method that adds an element, so a frozenset[Circle] satisfies frozenset[Shape], see Variance
dict[K, V] A dictionary with keys K and values V, see Type Hints
tuple[A, B] A fixed-length tuple (here a pair), see Type Hints
tuple[T, ...] A variable-length tuple of T, see Type Hints
Sequence[T], Iterable[T], Iterator[T], Mapping[K, V] Read-only abstract shapes from collections.abc; covariant in their element type, so list[Circle] satisfies Sequence[Shape] (Mapping[K, V]’s K stays invariant), see Variance
Generator[Y, S, R] A generator’s yield, send, and return types; Iterator[T] is enough when it only produces values, see Generators
Callable[[A, B], R] A function taking A, B and returning R (... for any parameters)
type[C] The class object C, not an instance of it, see Classes as Values
TypeForm[T] A type expression passed as a value, such as a union, see Classes as Values

Unions, Optionals, and Literals

Python documentation: unions, optionals, and literals

Construct Meaning
X | Y A union: either type, see Type Hints
X | None Optional: X or None, see Type Hints
Literal[...] One of a closed set of constant values, e.g. Literal["r", "w"], see The type Statement

Aliases and Distinct Types

Python documentation: aliases and distinct types

Construct Meaning
type Name = ... A name for a type, compound or scalar, e.g. type Coord = tuple[int, int], see The type Statement
NewType("Id", int) A distinct type, int at runtime but separate to the type checker; the base can be any class, not just a built-in
Annotated[T, meta] T carrying extra metadata for libraries and tools

Constants and Class Variables

Python documentation: constants and class variables

Construct Meaning
Final, Final[T] A name the type checker protects from reassignment, see Constants with Final
ClassVar[T] A class-level attribute, not one per instance, see Class Attributes

Generics

Python documentation: generics

Construct Meaning
def f[T](x: T) -> T A generic function (the type parameter varies per call), see Generic Functions and Classes
class Box[T] A generic class, see Generic Functions and Classes
[T: Base], [T: (int, str)] A bounded or constrained type parameter, see Type Parameters
[T = str] A type parameter default, used when you omit the brackets, see Type Parameter Defaults
TypeVar, Generic[T] The pre-3.12 way to write type parameters, see **P and the Older TypeVar Syntax
**P (ParamSpec) Captures a callable’s parameter list including types, for decorators, see Decorators
*Ts (TypeVarTuple), Unpack, Concatenate Variadic generics and parameter manipulation

Structural Typing

Python documentation: structural typing

Construct Meaning
Protocol A required shape (methods and attributes), satisfied without inheritance, see Structural Typing with Protocols
@runtime_checkable Allows isinstance() against a Protocol, see Surrogate

Dictionary and Record Shapes

Python documentation: dictionary and record shapes

Construct Meaning
TypedDict A dict with specific keys and value types; **kwargs: Unpack[TD] types keyword arguments with one, see Typing **kwargs with a TypedDict
Required[...], NotRequired[...], ReadOnly[...] Per-key control inside a TypedDict, see Typing **kwargs with a TypedDict
closed=True, extra_items=T Class arguments that limit a TypedDict to its declared keys, or type the values of the others, see Typing **kwargs with a TypedDict
NamedTuple A typed, named tuple class, see Data Transfer Objects

Type Narrowing

Python documentation: type narrowing

Construct Meaning
TypeGuard[T], TypeIs[T] A boolean predicate that narrows a type: TypeGuard narrows only where it returns True, TypeIs narrows both branches

Self and Forward References

Python documentation: self and forward references

Construct Meaning
Self The enclosing class type; useful for fluent methods and alternative constructors, see The Self Return Type
"Name" A forward reference to a not-yet-defined type; quoting is optional under deferred evaluation (PEP 649), see Composite and Interpreter

Typing Decorators and Directives

Python documentation: typing decorators and directives

Construct Meaning
@overload Several typed signatures for one function name
@override Declares that a method overrides a base-class method, see Classes
@final Forbids subclassing the class, or overriding the method, see Metaprogramming
cast(T, x) Tells the type checker to treat x as T; Flyweight shows the runtime guard to prefer over it
assert_never(x), assert_type(x, T), reveal_type(x) Type-checker assertions and aids; assert_never() shown in Pattern Matching
TYPE_CHECKING A flag that is True only to the type checker, for type-only imports, see Modules and Packages

These decorators and directives exist for the type checker and the reader. At runtime most of them return their argument. Two act when a call reaches them: assert_never() raises an AssertionError, and reveal_type() prints its argument’s runtime type.

Older code writes some of the constructs in these tables differently: Optional[X] for X | None, Union[X, Y] for X | Y, and List, Dict, Set, Tuple from typing for the lowercase built-ins. The forms above are the modern ones.

Exercises

Try each exercise before opening its solution.

  1. In protocols.py, add a class Triangle with its own draw(), and pass an instance to render() without changing Drawable or render().
  2. In area.py, remove the # type: ignore comment and run ty check on the file. Read the error, then restore the comment.
  3. In generics.py, write a second generic function, last[T](items: list[T]) -> T, that returns the final element, and call it on both a list[int] and a list[str] the way the listing calls first().
  4. In self_type.py, add a subclass of NamedTally called LoudTally whose report() returns the message in all capitals, calling super().report() first. Confirm that ty check accepts .bump().bump().report() on a LoudTally and that the call prints the message in capitals.
  5. Run ty check on type_defaults.py and read what it reports for reveal_type(words.top()). Remove the = str default and run it again. The type checker accepts the file either way. Say what that means for a bare Stack annotation.
  6. In type_aliases.py, call paint(grid, (2, 3), "purple") and run ty check. Read the error, then widen Color to admit "purple" and confirm the error goes away.
  7. In variance.py, change add_square()’s parameter annotation to Sequence[Shape] and uncomment the call. Explain why the type checker now accepts the call and why shapes.append(...) no longer type-checks.
  8. In narrowing.py, replace if text is not None: with if text: and run ty check. Explain why the empty string now takes the other branch even though the type checker accepts either version.
  9. In narrowing_attribute.py, copy b.val into a local variable before the if and use the local inside it. Replace the expect() call with print(show(box)) for a Box named box, then print box.val. Explain why the AttributeError disappears although reset() still runs.
  10. In final_constants.py, annotate HISTORY as Final[tuple[str, ...]], start it as an empty tuple, and run ty check. Read what the type checker reports for the append() call. Then change the listing so HISTORY holds "first", and explain why the entry must go into the definition of HISTORY.