Contents
Chapter 8

Static Types

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

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. Pyright checks the same listings as a second opinion, and the book’s build does not depend on its verdict. 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 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. object is the base of every class, so the type checker treats an object value as a checked value with the methods every class shares, while Any switches the checker off for that one value. Choose object for a value you store or pass along untouched, and Any for one whose operations you would rather check by hand.

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 names 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 any number of ints. 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. 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) 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. reveal_type(), imported from typing, asks the type checker which type it inferred for an expression, and ty check prints the answer as a diagnostic.

The # ty: summary is for you, not for the tool. ty acts on a comment that has the form # type: ignore, which silences every diagnostic on its line, or # ty: ignore[invalid-argument-type], which 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, 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. A truthiness test, if text:, narrows the same way, since None is falsy, but it sends the empty string to the other branch along with None, so test is not None when an empty value counts as supplied (see exercise 8). The same narrowing follows an isinstance() check, an equality test, or an identity test against a specific value such as is not SOME_SENTINEL.

An object parameter depends on narrowing. Gradual Typing says the type checker rejects every operation on an object beyond object’s own, so the 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

Inside the if, value is a str, so upper() checks. repr() accepts every object, so the return at [1] needs no test. With Any in place of object, the commented-out line checks too, and describe(42) then 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, and the type checker has no way to see inside that call:

# 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 nothing in the checker’s model connects reset() to that narrowing, so the checker keeps b.val narrowed to str. The AttributeError shows the narrowing was stale by the time upper() ran.

A narrowing on a local variable holds. A narrowing on an attribute can go stale, so recheck the attribute after any call that might touch the object. Copying the attribute into a local variable before the test avoids the recheck, because a function defined elsewhere cannot rebind its caller’s local variable (see exercise 9).

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 does on a non-Final list. The type checker refuses only an assignment to the name HISTORY. Final invites a misconception. 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 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. 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. Instead of waiting until the program runs, 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())

Neither Circle nor Square mentions 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.

Drawable appears in one place, the annotation on render()’s parameter. If you pass an object without a draw() to render(), the type checker rejects the call. 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 built-in 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 does not say 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 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. It is a keyword only at the start of this statement. Everywhere else, type is still the built-in 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.) Because an alias creates no new type, save it for a compound shape instead of using it to rename a built-in. 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 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. 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. first_any() accepts any list, and the return type then says nothing about what 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.nonexistent_method()
#: [AttributeError] '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 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") binds T to str for that instance, so get() returns a str and the call to upper() checks.

A bound limits the parameter to one hierarchy: class Box[T: Shape] accepts Shape and its subclasses, and T keeps the subclass it was given. A constraint lists unrelated choices: with [T: (int, str)], T is int or str, and an argument of a subclass of int binds T to int.

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 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 reads its argument and leaves it as it was, so the function accepts arguments that a list[T] 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 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

words: Stack names the class without its brackets, and the default makes that annotation mean Stack[str], so words.top() is a str and upper() checks.

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

Queue is the same class without the default, 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 (see exercise 5).

A type alias takes a default the same way, 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.

**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 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.

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 says nothing about 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. width is required, so style['width'] needs no guard. NotRequired makes fill optional, so label() reads it with .get() and a default.

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

A misspelled keyword is where the checkers disagree. Pyright rejects fill misspelled as fil="*" with No parameter named "fil", but ty 0.0.84 accepts that call without a diagnostic. At runtime fil becomes an extra key in style, and label() ignores that key, so the fill quietly stays a space.

The payoff grows when several functions accept the same options. One TypedDict declares them once, and every signature that unpacks it stays in step.

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 does 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.

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. Each subsection opens with a link 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 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 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 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 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 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
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 Simulation

These exist for the type checker and the reader, and at runtime most of them do nothing. 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 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

This chapter’s solutions give a hint, usually the shape of the code, and a full answer for each exercise.

  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. The type checker 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 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.