Contents
Chapter 8

Static Types

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 almost no type declarations, and on a small program you may not miss them.

Python 3.5 (2015) introduced type hints, 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 something reads the annotations. 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, and pyright is the one most editors run. This book uses Astral’s ty instead, from the same group that makes uv and ruff. Every listing passes ty before it reaches the page, and “the type checker” in this book means ty unless a sentence says otherwise. Pyright runs over the same listings as a second opinion, outside that gate. Where it 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 annotations still works. The type checker treats it 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 earn their keep: the public interfaces, the tricky data, the code on which other people depend. An explicit Any indicates that a value is truly dynamic. ty reports the Any that comes from a missing annotation as Unknown, to distinguish it from an Any you wrote yourself. They behave the same: both are compatible with everything.

Any and object differ. Both accept every value, but object guarantees nothing about the value once you have it, so the type checker rejects every operation beyond object’s own. Any permits every operation instead, so it opts out of checking rather than describing a wide set of values.

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 function that returns nothing declares -> None, and that is why every __init__() in this chapter’s listings carries that annotation.

The Type Checker: ty

The hints do nothing on their own. You need a tool to check them:

ty check

uvx ty check runs it without installing anything, and uv tool install ty@latest puts it on your path.

ty complains where the hints and the code disagree, and stays quiet when they agree. This book checks every runnable example this way. 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. Consider:

# 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) runs without error. 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 discovers the problem immediately.

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: the diagnostic for a line that would fail the check, whether commented out or suppressed with # type: ignore, or the type a reveal_type() call produces.

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, and not just 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 .upper() needs no cast. Outside the if, text is still the full str | None. The same narrowing follows an isinstance() check, an equality test, or an identity test against a specific value such as is not SOME_SENTINEL.

Narrowing an attribute is riskier than narrowing a local variable. A call between the check and the use can reset that attribute, and the type checker has no way to see inside that call:

# narrowing_attribute.py

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

try:
    show(Box("hi"))
except AttributeError as e:
    print(e)
#: 'NoneType' object has no attribute 'upper'

ty check passes this file. The if narrows b.val to str, and nothing in the checker’s model connects reset() to that narrowing, so the checker never widens b.val back to str | None. The crash proves the narrowing was already stale by the time .upper() ran. A narrowing on a local variable holds; a narrowing on an attribute can go stale, so recheck it after any call that might touch the object.

Constants with Final

Final on a name makes the type checker catch 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, and 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']

Final blocks rebinding the name, not mutation of the object the name holds. HISTORY.append("first") checks and runs, the same as it would on a non-Final list. The type checker refuses only an assignment to the name HISTORY. This is the misconception Final invites: the word suggests immutability, but the object stays mutable.

You can give the type explicitly, as in GREETING, or let the type checker infer it from the value, as with MAX_RETRIES. The rest of the book uses the explicit Final[T] form, and that form declares the intended type instead of accepting whatever the initializer produces. The two forms differ when the initializer says less than you mean. CACHE: Final = [] infers list[Unknown], so the type checker ignores whatever goes into the list. CACHE: Final[list[str]] = [] says what the list holds, and the type checker enforces it.

Structural Typing with Protocols

Earlier chapters relied 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. Instead of waiting until the program is running, 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 trusts the object once the code is running, while structural typing proves the shape beforehand.

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

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

Circle and Square never mention Drawable. The type checker accepts both because each has a draw() that takes no arguments and returns a 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, does not match. A Protocol is a checking-time construct, so isinstance(Circle(), Drawable) raises a TypeError instead of answering. Decorating the Protocol with @runtime_checkable allows the call, at the cost of a weaker check: see Surrogate.

Drawable annotates render()’s parameter alone. If you pass an object without a draw() to render(), ty rejects it. Blob is the case worth watching: it draws, in the everyday sense, but the method’s name is paint(), and a protocol matches on names and signatures rather than on intent. Protocols preserve the flexibility of dynamic typing but add the early warning of static type checking.

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

class Shape:
    pass

class Circle(Shape):
    pass

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

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

make() takes the class, not an instance, so the argument’s annotation is type[Shape]. Passing Circle works because Circle is a subclass of Shape. Calling kind() then produces an instance. The word type plays two roles in this listing: the annotation type[Shape] names the class to the type checker, while the builtin call type(shape) in the demo retrieves an object’s class at runtime.

Naming Types: The type Statement

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

# type_aliases.py
from typing import Literal

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

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

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

Like match, type is a soft keyword (Control Flow): it is a keyword only at the start of this statement. Everywhere else, type is still the builtin type() function, so type(grid) in the same file returns dict as it always has.

A type alias is 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. (For a type the type checker keeps separate from its base, use NewType, listed under Aliases and distinct types.) Because an alias creates no new type, save it for a compound shape instead of using it to rename a builtin: type UserId = int looks like a new type in a signature while behaving like int.

Color names 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 is the lightest way to close a set of values. Once 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 fixed, 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

Consider a function that returns the first element of a list. This function works on 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: it accepts any list, and the return type then says nothing about what the list holds:

# first_any.py
from typing import Any

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

n = first_any([10, 20, 30])
try:
    n.nonexistent_method()
except AttributeError as e:
    print(e)
#: 'int' object has no attribute 'nonexistent_method'

ty check passes this file with no complaint. n is Any, so every attribute access on it type-checks, including one that runs and fails. A type parameter closes this hole.

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
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 then knows the return type. Both n + 1 and s.upper() pass the type checker, while n.upper() fails.

A class declares type parameters the same way:

# 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

Constructing Box("gift") fixes T to str for that instance, so get() returns a str and the call to upper() checks. A bound constrains the parameter: class Box[T: Shape] accepts only Shape and its subclasses.

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

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

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

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

A list accepts writes. add_square() would append a Shape to a list its caller believes holds only circles. The type checker refuses the call to prevent that. A read-only container has no such problem, so Sequence[Shape] accepts a list[Circle]. Annotating a parameter Sequence[T] instead of list[T] declares that the function only reads, so the function accepts arguments that a list[T] parameter would reject. 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 names the class without its brackets:

# type_defaults.py

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")
print(words.top().upper())
#: BETA
counts: Stack[int] = Stack()
counts.push(2)
print(counts.top() + 1)
#: 3

Without the default, words: Stack leaves T unsolved and the type checker falls back to Unknown, so words.top().upper() goes unchecked. The default gives the bare form a meaning, which matters most for a class whose type parameter is usually the same type: callers who want that type omit the brackets, and the annotation stays precise.

If you drop the default, that meaning goes with it. Queue[T] carries none, so a bare Queue annotation leaves T unsolved:

# type_defaults_bare.py
from typing import reveal_type

class Queue[T]:
    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]

line: Queue = Queue()  # No brackets, T unsolved
line.push("first")
reveal_type(line.top())  # ty: Unknown

ty reports Unknown, not an error, so line.top() and everything built on it go unchecked from here. Exercise 5 asks you to remove Stack’s default and see the same result.

The same applies to a type alias, 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. Type parameter defaults arrived in Python 3.13, one release after the bracket 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 Self Return Type

A method that returns its own instance allows call chaining. What should the type annotation be? Naming the enclosing class works until someone inherits from it. Self means “an instance of the class on which you called this method,” so it automatically adapts to subclassing:

# 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 benefit the same way: a @classmethod that ends with return cls(...) returns Self, so a call on a subclass produces an instance of that subclass, not of the base.

Hints Are Not Enforced at Run Time

Type hints do not change what the program does. Python stores them and otherwise ignores them. A wrong type that slips past the type checker behaves as it would have without hints. Checking is a separate step you run, the same way you run tests. 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, and that suits the edges of a program, where untrusted input enters. The hints are for the tools and for the reader.

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. The following chapters do not repeat that Python itself would run the line anyway.

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 travels farthest before it fails.

Let the type checker infer the rest. A local variable whose type is obvious from its initializer gains nothing from an annotation, and count: int = 0 says no more than count = 0 does, at greater length. (The total: int = 0 in this chapter’s first listing shows the syntax, not a recommendation.) The value of a hint is proportional to the distance between a value’s creation and its use. A value born and consumed three lines later needs no help. A value that arrives from another module, inside a container, is worth annotating precisely.

Type Hint Summary

These are the type hints you encounter, in their modern forms. The book uses only a handful of these, but the rest turn up in other code. Each subsection heading links to the associated Python documentation. Thinking in Types explores types in more depth.

Annotations 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, but with no behavior assumed (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 nothing can be written into it, 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

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 fixed 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 type alias for a longer type, e.g. type Grid = dict[tuple[int, int], str], 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 builtin
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 does not let you reassign, 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 Generic Functions and Classes
[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 Generic Functions and Classes
**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
Required[...], NotRequired[...], ReadOnly[...] Per-key control inside 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 Simulation

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 Simulation

The runtime ignores all of these. They exist for the type checker and the reader. Older code writes some of them 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

  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 .bump().bump().report() still chains correctly on a LoudTally.
  5. Add reveal_type(words.top()) to type_defaults.py and run ty check on the file. Remove the = str default and run it again. ty reports no error 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 ty 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.