20  Type Hints in Functions

NoteCore idea

Type hints are optional, checked by tools (mypy, pyright), and have zero runtime cost. They are documentation that can be verified. Python’s type system is “gradual” — you add hints incrementally.

In this chapter you will learn to:

  1. Add type hints to functions and read what they document.
  2. Use Optional, Union, and the modern X | Y syntax for choices.
  3. Annotate generic collections — list[int], dict[str, float], tuple[int, ...].
  4. Prefer abstract types (Iterable, Sequence, Mapping) for parameters.
  5. Use TypeVar to write functions that preserve element types.
  6. Define structural interfaces with typing.Protocol.

20.1 Gradual typing in practice

A function without hints is still legal:

def show_count(count, word):
    if count == 1:
        return f"1 {word}"
    return f"{count or 'no'} {word}s"

show_count(3, "cat")
'3 cats'

Without hints, you can call show_count("three", "cat") and the bug only surfaces when the format string runs. Hints let a static checker catch this before the program runs:

def show_count(count: int, word: str) -> str:
    if count == 1:
        return f"1 {word}"
    return f"{count or 'no'} {word}s"

show_count(1, "fish"), show_count(0, "bird"), show_count(3, "cat")
('1 fish', 'no birds', '3 cats')

Walking through the syntax:

  • count: int annotates the parameter — “I expect count to be an int.” The colon plus type expression is Python’s annotation syntax; it works on parameters, return values, and bare assignments alike.
  • word: str is the same for the second parameter.
  • -> str annotates the return type — “this function returns a str.” The arrow goes between the parameter list and the body’s colon.
  • None of this affects runtime. Python parses and stores the annotations on the function object (show_count.__annotations__) but doesn’t enforce them. A static checker like mypy or pyright reads them and flags mismatches; the interpreter shrugs.

The general pattern: hints are documentation that a tool can verify. Add them when they earn their keep — usually at API boundaries where wrong types would cause real bugs.

For optional parameters, write X | None (Python 3.10+) or Optional[X]:

def show_count(count: int, singular: str, plural: str | None = None) -> str:
    if count == 1:
        return f"1 {singular}"
    return f"{count or 'no'} {plural or singular + 's'}"

show_count(1, "fox"), show_count(2, "fox"), show_count(2, "child", "children")
('1 fox', '2 foxs', '2 children')

20.2 Types are defined by supported operations

The duck-typing view is that a type is what a value can do:

def double(x):
    return x * 2

double(2), double("ab"), double([1, 2])
(4, 'abab', [1, 2, 1, 2])

int, str, and list all support * — and double works on all three. The type-system view writes the same idea using TypeVar:

from typing import TypeVar
T = TypeVar("T", int, str, list)
def double_typed(x: T) -> T:
    return x * 2

double_typed(2), double_typed("ab")
(4, 'abab')

Now mypy knows that double_typed(2) returns int, not “anything”.

20.3 Types you’ll annotate with

The everyday vocabulary:

from typing import Any
def legacy_parse(text: str) -> Any: ...

def parse(text: str) -> float | None:
    try:
        return float(text)
    except ValueError:
        return None

parse("3.14"), parse("hello")
(3.14, None)

Any is the type-system escape hatch — “don’t check this.” A value annotated as Any is compatible with every other type, in both directions: a checker neither flags assigning it to anything nor flags assigning anything to it. Use it where you genuinely don’t know — JSON decoding, plug-in interfaces, legacy code being incrementally typed — and prefer a more specific type everywhere else.

def normalize_label(label: int | str) -> str:
    if isinstance(label, int):
        return str(label)
    return label

normalize_label(42), normalize_label("forty-two")
('42', 'forty-two')

For collections, since Python 3.9 you can subscript the built-ins directly — list[int], dict[str, int], tuple[int, ...] all work without from typing import List, Dict, Tuple:

def tokenize(text: str) -> list[str]:
    return text.upper().split()

tokenize("hello world")
['HELLO', 'WORLD']

text: str annotates the input as a string; -> list[str] annotates the return as “a list whose elements are strings.” The body uppercases the input and splits on whitespace, producing ['HELLO', 'WORLD'] — a list of str, exactly as advertised.

from collections.abc import Iterable

def word_lengths(words: Iterable[str]) -> dict[str, int]:
    return {w: len(w) for w in words}

word_lengths(["hi", "hello"])
{'hi': 2, 'hello': 5}

Iterable[str] is intentionally permissive: a list works, a tuple works, a generator works — anything you can for-over whose elements are strings. dict[str, int] is the return: a dict whose keys are strings and values are ints. The body is a dict comprehension {w: len(w) for w in words} — for each word, map it to its length. Output: {'hi': 2, 'hello': 5}.

def divmod_pair(a: int, b: int) -> tuple[int, int]:
    return divmod(a, b)

divmod_pair(17, 5)
(3, 2)

tuple[int, int] annotates a fixed-length, two-element tuple where both slots are ints. divmod(17, 5) returns the pair (quotient, remainder)(3, 2) here, because 17 = 3 * 5 + 2. The annotation tells the type checker that callers can safely unpack into two integer-typed names: q, r = divmod_pair(17, 5) will see q: int, r: int.

tuple[int, int] is a fixed-length tuple. tuple[int, ...] (with the literal ...) is a variable-length homogeneous tuple.

Tipfrom __future__ import annotations

Putting this at the top of a module turns all annotations into strings (PEP 563 — postponed evaluation). Two practical wins: forward references work without quoting (def f() -> "MyClass": becomes def f() -> MyClass:), and there is zero runtime cost for evaluating the annotation expression. Tools like mypy still type-check normally; introspection-heavy libraries (pydantic, dataclasses in some modes) need to opt in.

When a parameter is a function, you’d like the type hint to record both what arguments it takes and what it returns, so a wrong-shaped callback gets caught. That’s Callable:

from collections.abc import Callable
def apply(func: Callable[[int], str], value: int) -> str:
    return func(value)

apply(str, 42)
'42'

Walking through the Callable syntax:

  • Callable[[int], str] reads “a callable that takes one int and returns a str.” The first bracketed list is the argument types (in order); the second item is the return type.
  • For a callable taking no arguments, you write Callable[[], str] — empty inner list. For a variadic callable where you don’t want to spell the args, write Callable[..., str] with the literal ....
  • apply(str, 42) type-checks because str is Callable[[int], str]-compatible (calling str(42) produces "42"). Passing apply(len, 42) would still work at runtime for many inputs, but mypy would object — len returns int, not str.

20.4 Abstract types beat concrete types

For parameters, prefer the most abstract type that supports the operations you need. Iterable is the most permissive; Sequence adds indexing; Mapping is the read-only dict shape.

from collections.abc import Sequence, Mapping, Iterable

def column(matrix: Sequence[Sequence[float]], i: int) -> list[float]:
    return [row[i] for row in matrix]

column([[1, 2], [3, 4], [5, 6]], 0)
[1, 3, 5]

Walking through the type chosen here:

  • Sequence[Sequence[float]] says “a thing you can iterate and index, whose elements are themselves indexable sequences of floats.” list[list[float]] would also work for the test call, but Sequence is more permissive — a tuple of tuples would be accepted too.
  • We need Sequence (not Iterable) on the inner type because row[i] needs indexing. Picking the minimum abstraction the body actually needs is the discipline.
  • The return is list[float] — concrete, because callers benefit from knowing it’s a real list they can index, append to, and pass on.
def total(items: Iterable[int]) -> int:
    return sum(items)

total([1, 2, 3]), total(range(4)), total(n*n for n in range(5))
(6, 6, 30)

total accepts any iterable — list, range, generator. If the parameter were list[int], the generator wouldn’t type-check.

Mapping[K, V] is the read-only dict shape — accept a dict, a ChainMap, anything with __getitem__ and keys:

def lookup(table: Mapping[str, int], key: str) -> int:
    return table.get(key, 0)

lookup({"a": 1, "b": 2}, "a")
1

The opposite rule applies to return types: be concrete. Returning list[int] is more useful than returning Iterable[int], because the caller can rely on indexing and length.

20.5 TypeVar and parameterized generics

first(seq) should return the same kind of thing the sequence holds — int in, int out; str in, str out. Annotating it as Any -> Any loses that information; spelling it out as int -> int plus str -> str plus everything else is impossible. TypeVar lets you write it once and let the type checker fill in the type at each call:

from typing import TypeVar
from collections.abc import Sequence

T = TypeVar("T")
def first(seq: Sequence[T]) -> T:
    return seq[0]

first([1, 2, 3]), first(["a", "b"])
(1, 'a')

Walking through this:

  • T = TypeVar("T") introduces a type variable — a placeholder a checker will resolve at each call site. The string "T" must match the variable name; that’s just how TypeVar is spelled.
  • Sequence[T] says “a sequence whose elements are some type T.” -> T says “I return that same type.”
  • At the call first([1, 2, 3]), the checker matches Sequence[T] against list[int] and infers T = int, so the return type is int. At first(["a", "b"]) it infers T = str. The single function is generic over the element type.

A constrained TypeVar lists allowed types — the value must be exactly one of them:

from decimal import Decimal
from fractions import Fraction
from collections.abc import Iterable

NumberT = TypeVar("NumberT", float, Decimal, Fraction)
def average(data: Iterable[NumberT]) -> NumberT:
    items = list(data)
    return sum(items) / len(items)

average([1.0, 2.0, 3.0])
2.0

A bounded TypeVar (using bound=) accepts the type or any subclass:

from numbers import Real
RealT = TypeVar("RealT", bound=Real)
def clamp(x: RealT, lo: RealT, hi: RealT) -> RealT:
    return max(lo, min(x, hi))

clamp(5, 0, 10), clamp(3.14, 0.0, 1.0)
(5, 1.0)

Use bound= when “any subclass of X” is the intent; use the constrained form when only a closed set of types is allowed.

20.6 Static Protocol types

typing.Protocol is the type-system spelling of duck typing. You declare an interface; any class with matching methods is compatible — no inheritance required. Build it in three small steps.

Step 1: declare a protocol. A Protocol subclass with method signatures (no bodies — ... is the placeholder) defines a shape:

from typing import Protocol

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

class Circle:
    def draw(self) -> None:
        print("Drawing circle")

def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())
Drawing circle
  • class Drawable(Protocol): declares a structural type — anyone with the listed methods qualifies.
  • def draw(self) -> None: ... is a method signature. The ... is the body placeholder — the protocol describes the shape, never executes.
  • Circle does not inherit from Drawable. It just happens to have a matching draw method, so render(Circle()) is accepted by the type checker.

Step 2: see that isinstance does not work yet. A bare Protocol is a static-only contract. Try to use it at runtime and Python refuses:

isinstance(Circle(), Drawable)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[19], line 1
----> 1 isinstance(Circle(), Drawable)

File /opt/hostedtoolcache/Python/3.13.13/x64/lib/python3.13/typing.py:2132, in _ProtocolMeta.__instancecheck__(cls, instance)
   2126     return _abc_instancecheck(cls, instance)
   2128 if (
   2129     not getattr(cls, '_is_runtime_protocol', False) and
   2130     not _allow_reckless_class_checks()
   2131 ):
-> 2132     raise TypeError("Instance and class checks can only be used with"
   2133                     " @runtime_checkable protocols")
   2135 if _abc_instancecheck(cls, instance):
   2136     return True

TypeError: Instance and class checks can only be used with @runtime_checkable protocols
  • By default Python won’t introspect a protocol — that would be expensive and isn’t what the type system needs.
  • mypy and other static checkers happily verify Drawable compatibility without ever calling isinstance. Runtime introspection is opt-in.

Step 3: opt in with @runtime_checkable. When you genuinely need the runtime check (dispatching on shape, validating untyped input at a boundary), apply the decorator:

from typing import runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None: ...

class Square:
    def draw(self) -> None:
        print("Drawing square")

isinstance(Square(), Drawable), isinstance("not a shape", Drawable)
(True, False)
  • @runtime_checkable tells Python “build the introspection machinery so isinstance works.”
  • The check is structural — it asks whether the object has all the named methods. It does not verify signatures, so a class with the wrong signature still passes isinstance (only mypy catches that).
  • Use it sparingly. Most protocols can stay static-only; reach for @runtime_checkable only at the boundary between typed and untyped code.

The general pattern: Protocol is duck typing made checkable. You write the shape you depend on; any class that fits is accepted; nothing has to inherit from anything. We will return to protocols, ABCs, and the line between them in Chapter 25.

TipWhy this matters

“Types are defined by supported operations” — this is duck typing expressed as a type system. The Protocol class formalizes duck typing: define the interface, and any class that implements those methods satisfies the protocol, without inheriting from anything.

20.7 Build: a typed Repository[ModelT] over any record

A repository is the standard “store and look up records by id” abstraction. Building a generic one is the canonical workout for Protocol, TypeVar with a bound=, and the modern PEP 695 generic syntax.

Step 1: a Protocol for “has an id.” Any record we want to store must expose an integer id. A Protocol says exactly that without forcing the model classes to inherit anything:

from typing import Protocol

class HasId(Protocol):
    id: int

# Any class with an `id: int` is a HasId — no inheritance needed.
from dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str

@dataclass
class Order:
    id: int
    total: float

User(1, "Alice"), Order(101, 99.99)
(User(id=1, name='Alice'), Order(id=101, total=99.99))

HasId declares an attribute (not a method) protocol. Any class with an id: int slot is a HasIdUser and Order qualify without ever importing HasId. That’s structural typing applied to fields rather than methods.

Step 2: a generic Repository parameterised over the model type. The PEP 695 [ModelT: HasId] syntax declares a type parameter scoped to the class — ModelT must be HasId-compatible:

class Repository[ModelT: HasId]:
    def __init__(self) -> None:
        self._store: dict[int, ModelT] = {}

    def save(self, item: ModelT) -> ModelT:
        self._store[item.id] = item
        return item

    def get(self, id: int) -> ModelT | None:
        return self._store.get(id)

    def all(self) -> list[ModelT]:
        return list(self._store.values())

users: Repository[User] = Repository()
users.save(User(1, "Alice"))
users.save(User(2, "Bob"))
[users.get(1), users.get(99), users.all()]
[User(id=1, name='Alice'),
 None,
 [User(id=1, name='Alice'), User(id=2, name='Bob')]]

[ModelT: HasId] reads “a type parameter ModelT bounded by HasId.” Inside the class, every ModelT annotation refers to the same type — so save returns whatever ModelT is, and get returns ModelT | None. A type checker treats users.save(User(1, "Alice")) as User-typed without us writing User twice, and users.save(Order(...)) would be flagged as a type error.

Step 3: separate repositories for separate models — no code duplication. The same generic class produces fully-typed Repository[User], Repository[Order], etc., each with its own storage:

orders: Repository[Order] = Repository()
orders.save(Order(101, 99.99))
orders.save(Order(102, 49.50))

[users.all(), orders.all(),
 # Type-level check: users and orders are separate dicts
 users.get(101) is None]
[[User(id=1, name='Alice'), User(id=2, name='Bob')],
 [Order(id=101, total=99.99), Order(id=102, total=49.5)],
 True]

users and orders are separate Repository instances with different element types — mypy would flag users.save(Order(...)) as Order is incompatible with User. The _store dicts are independent (no cross-contamination), and users.get(101) returns None because that id was only saved on orders.

The build threads the chapter’s three biggest ideas through one concrete example: a Protocol to describe the shape of the model (Step 1), a TypeVar (via the modern [ModelT: HasId] syntax) so the same class works for any conforming model (Step 2), and the structural-typing payoff that you get strong, model-specific types without any inheritance from a base class (Step 3).

20.8 Exercises

  1. Annotate tokenize. Add type hints to tokenize from the chapter so that the input is “anything that can be .upper().split()-ed and returns a list of strings.” Check with mypy --strict.

  2. Optional vs | None. Convert a function with Optional[int] parameter to use the int | None syntax. Are the two equivalent under mypy?

  3. A bounded TypeVar. Write def median(values: Iterable[T]) -> T where T is bounded to int | float | Decimal. Make sure passing a list of strings is rejected by the type checker.

  4. A Protocol for sized. Define a Sized protocol with a __len__ method. Annotate def percent_full(x: Sized, capacity: int) -> float to compute len(x) / capacity * 100.

  5. When Any is honest. Write a function that accepts an arbitrary JSON-decoded value and returns its depth. Annotate the parameter as Any. Why is Any more honest here than int | str | list | dict | None?

20.9 Summary

Type hints are gradual: you add them where they pay rent. The everyday vocabulary — list[T], tuple[T, ...], Optional, Iterable, TypeVar, Protocol — is enough for nearly everything. We’ll revisit the harder parts (variance, TypedDict, overload) in Chapter 27 once classes and protocols are on the table.

Next, Chapter 21 is where first-class functions earn their keep at scale: decorators, closures, and the standard-library decorators that you’ll use every day.