GAP·MAP

Python type hints

[ middle depth ]

Type hints are not enforced at runtime

CPython reads your annotations, stores them, and then ignores them while the code runs. def area(r: float) -> float accepts any argument, and count: int = "nope" binds the string without complaint. Nothing checks, coerces, or casts.

def double(n: int) -> int:   # the runtime never looks at these
    return n * 2
double("ab")                 # runs, returns "abab"

The hints exist for two audiences. Static checkers (mypy, pyright) read them offline and report mismatches before the code ships. Some libraries opt in at runtime by introspecting the annotations themselves: dataclasses build __init__, pydantic validates. That validation is the library's own logic, not the language enforcing anything.

Read annotations with typing.get_type_hints(obj) rather than the raw __annotations__ dict, because it resolves string forward references and hands back real type objects.

Optional means can-be-None, not optional argument

Wrong "I marked the argument Optional so callers can leave it out." Optional[int] is exactly Union[int, None], also spelled int | None. It describes the value: the value may be an int or it may be None. It says nothing about whether the argument is required.

Right an argument is optional when it has a default.

def send(retries: int = 3): ...           # optional argument, no Optional needed
def scale(factor: int | None): ...         # required argument, value may be None
def scale(factor: int | None = None): ...  # both: omittable and nullable

Calling def send(retries: Optional[int]) with no argument still raises a missing-argument TypeError, because there is no default to fall back on.

Protocol matches by structure, not inheritance

A class satisfies a Protocol when it has the required members. No inheritance, no registration.

from typing import Protocol
class Closable(Protocol):
    def close(self) -> None: ...

class Handle:                  # does not inherit Closable
    def close(self) -> None: ...

def shut(x: Closable) -> None: x.close()
shut(Handle())                 # accepted: structural match

This is static duck typing. Any object with a compatible close fits, including a built-in file object. Inheriting from the Protocol is allowed, but it only asks the checker to verify you implemented every member.

Modern annotation syntax

Since 3.9 use built-in generics: list[int], dict[str, int], tuple[int, ...], in place of typing.List and friends. Since 3.10 write unions as X | Y (int | str) instead of Union[int, str], and X | None instead of Optional[X]. These are the current spellings; the old typing aliases still import but are deprecated.

[ senior depth ]

TypeVar variance: why list[int] is not a subtype of list[float]

Given that B is a subtype of A, an invariant generic G makes neither G[B] nor G[A] a subtype of the other. Mutable containers are invariant. list[int] is not a subtype of list[float], even though the checker treats every int as a float, because code holding the list as list[float] could append a genuine float and corrupt the int list.

Read-only positions relax this. Sequence[int] is a subtype of Sequence[float] (covariant: values only come out). Callable[[Shape], int] is a subtype of Callable[[Triangle], int] (contravariant in arguments: a function taking any Shape stands in where a Triangle consumer is expected). Covariant tracks outputs, contravariant tracks inputs, and anything read and written stays invariant.

from typing import TypeVar, Generic
T_co = TypeVar("T_co", covariant=True)   # suffix convention: _co / _contra
class ReadOnlyBox(Generic[T_co]):
    def get(self) -> T_co: ...           # T_co appears only in output position

runtime_checkable verifies presence, not signatures

@runtime_checkable lets isinstance() run against a Protocol, but the check is shallow.

@runtime_checkable
class Reader(Protocol):
    def read(self, n: int) -> bytes: ...

class Broken:
    def read(self): return b""           # wrong signature, no n
isinstance(Broken(), Reader)             # True: only checks that read EXISTS

The isinstance check confirms the named members are present and inspects neither their argument counts nor their types. issubclass against a Protocol works only for method-only (non-data) protocols, and parameterized checks such as isinstance(x, Reader[int]) raise TypeError. A runtime_checkable Protocol is a presence probe, never a validator.

from future import annotations and deferred evaluation

from __future__ import annotations (PEP 563) turns every annotation into a lazy string, so __annotations__ holds 'list[int]', not the object. Runtime consumers must call get_type_hints() to resolve them, and code that reads the raw dict expecting types breaks silently.

In 3.14 (PEP 649) deferred evaluation becomes the built-in model: annotations compute on demand through a descriptor, and the new annotationlib module introspects them. This eases forward-reference and circular-import pain without stringizing everything.

PEP 695 native generics in 3.12

Since 3.12 you can declare type parameters inline, with no TypeVar import.

def first[T](seq: Sequence[T]) -> T: ...   # generic function
class Box[T]: ...                           # generic class
type Vec[T] = list[tuple[T, T]]             # lazily evaluated alias

With this form you do not annotate variance. The checker infers it per usage: a read-only attribute yields covariance, a settable public attribute forces invariance. That inference is the same mutable-equals-invariant rule applied in reverse: read-only usage can be covariant, a settable public attribute cannot.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Python

was this useful?