Advanced Type System
@overload — Multiple Signatures for One Function
Section titled “@overload — Multiple Signatures for One Function”Python is dynamically typed: a single function object can be called with any combination of Arguments. But type checkers need to know what types are acceptable and what the return type is for Each valid combination. typing.overload solves this by letting you declare multiple signatures for The same callable, followed by a single implementation that carries the actual runtime logic.
Mechanism
Section titled “Mechanism”The @overload decorator does nothing at runtime. It is a no-op that returns the decorated Function unchanged. Its sole purpose is to signal to static type checkers (mypy, pyright, Pyright-based editors) that the decorated function has multiple type signatures. At runtime, only The implementation function exists; the overload definitions are effectively erased.
The pattern is always: one or more @overload-decorated stubs (with no body, using ...), then a Single implementation:
from typing import overload
@overloaddef process(data: str) -> str: ...@overloaddef process(data: bytes) -> bytes: ...@overloaddef process(data: list[str]) -> list[str]: ...
def process(data): if isinstance(data, str): return data.strip() if isinstance(data, bytes): return data.strip() if isinstance(data, list): return [item.strip() for item in data] raise TypeError(f"Unsupported type: {type(data)}")Each overload stub must be consistent in the sense that a type checker can determine which overload Applies at each call site. The implementation signature is not checked against the overloads — the Implementation can have a broad signature like def process(data: Any) -> Any: and type checkers Will not complain, because they understand that the implementation is the fallback.
Overloads with @staticmethod and @classmethod
Section titled “Overloads with @staticmethod and @classmethod”Overloads compose with other decorators, but the order matters. The @overload must be the Innermost decorator (closest to the function definition), and @staticmethod/@classmethod must Wrap it:
from typing import overload
class Parser: @overload @staticmethod def parse(raw: str) -> dict[str, object]: ... @overload @staticmethod def parse(raw: bytes) -> dict[str, object]: ...
@staticmethod def parse(raw): import json if isinstance(raw, bytes): raw = raw.decode("utf-8") return json.loads(raw)The reason for this ordering: @staticmethod and @classmethod are descriptor-based decorators That transform the function object into a different kind of descriptor. If you put @overload on Top, it would try to decorate the result of @staticmethod (a staticmethod descriptor), which is Not a function and would confuse the type checker”s overload tracking. The type checker needs to see @overload applied to a plain function so it can extract the signature.
Limitations
Section titled “Limitations”- Runtime erasure: At runtime, only the implementation exists. You cannot inspect overloads programmatically via
__annotations__ortyping.get_overloads()(the latter exists in Python 3.11+ but is rarely used). - Type checker only:
@overloadhas zero effect on runtime behavior. If you call a function with arguments that match none of the overloads, the code will still execute — it just means the type checker will flag a type error. - No exhaustiveness checking: The implementation body has no obligation to handle every overload case. A missing branch will only surface at runtime as an unhandled case.
- Overload resolution is based on call-site argument types. If the argument type is a
Unionthe type checker attempts to match against each overload’s parameter types individually and picks the first match. This means the order of overloads matters when signatures overlap.
Literal Types
Section titled “Literal Types”Literal types let you specify that a value must be exactly one of a finite set of literal values. This is fundamentally different from saying the value is of type str or int — it constrains the Value to a specific member of that type.
Syntax and Semantics
Section titled “Syntax and Semantics”from typing import Literal
def set_verbosity(level: Literal["debug", "info", "warning", "error"]) -> None: ...
set_verbosity("debug") # OKset_verbosity("verbose") # type error: not in the literal setLiteral accepts strings, bytes, integers, booleans, NoneAnd enum values. At the type-system Level, Literal["foo"] is a subtype of str``Literal[42] is a subtype of intAnd Literal[True] is a subtype of bool. The type checker treats each literal value as a distinct Type.
from typing import Literal
x: Literal[True] = True # OKx: Literal[True] = False # type errorx: Literal[1, 2, 3] = 2 # OKx: Literal[1, 2, 3] = 4 # type error
def http_method(method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"]) -> str: return method.upper()Narrowing with match and isinstance
Section titled “Narrowing with match and isinstance”Type checkers perform literal narrowing. After an if or match check against a literal value, the Type is narrowed to that specific literal:
from typing import Literal
def handle_status(status: Literal["open", "closed", "pending"]) -> str: match status: case "open": return "active" case "closed": return "done" case "pending": return "waiting"This is not just syntactic sugar over str. The type checker knows that after the "open" case, status is typed as Literal["open"]Not str. This enables downstream type-safe behavior.
LiteralString (Python 3.11+)
Section titled “LiteralString (Python 3.11+)”typing.LiteralString is a special type that accepts only string literals and strings that are Computed from other LiteralString values. It rejects arbitrary str values (e.g., user input, Values read from files, values returned from function calls that return str).
The purpose is security: functions that construct SQL queries, shell commands, or HTML from string Interpolation should require LiteralString inputs to prevent injection:
from typing import LiteralString
def query(sql: LiteralString, *args: object) -> None: ...
table_name: str = input("Table name: ") # type: str, NOT LiteralString## query(f"SELECT * FROM {table_name}") # type error
query("SELECT * FROM users") # OK -- literalquery("SELECT * FROM " + "users") # OK -- literal + literal = LiteralStringUse Cases
Section titled “Use Cases”- Status enums without
Enum: When you don’t need the full machinery ofenum.Enum``Literaltypes provide the same safety with less boilerplate. - API versioning:
def api_call(version: Literal["v1", "v2"]) -> Responseensures callers pass a known version string. - Protocol identifiers:
def connect(protocol: Literal["tcp", "udp", "unix"])documents the valid transport protocols at the type level.
TypedDict
Section titled “TypedDict”TypedDict provides a way to specify type information for dictionaries with a fixed set of string Keys, each mapped to a value of a specific type. It bridges the gap between the flexibility of dict and the structure of a dataclass or NamedTuple.
Two Syntax Forms
Section titled “Two Syntax Forms”There are two ways to create a TypedDict: functional (anonymous) and class-based.
from typing import TypedDict
Movie = TypedDict("Movie", {"name": str, "year": int})The functional form is a call that returns a new type. The first argument is the name of the type (string), and the second is a dictionary mapping field names to their types.
The class-based form is more common and supports more features:
from typing import TypedDict
class Movie(TypedDict): name: str year: intBoth forms produce identical types. The class-based form is preferred because it supports Docstrings, methods (though you rarely need them), and the total keyword argument more .
Required vs Optional Keys
Section titled “Required vs Optional Keys”By default, all keys in a TypedDict are required. To make individual keys optional, use NotRequired (Python 3.11+, or from typing_extensions):
from typing import TypedDict, NotRequired
class Movie(TypedDict): name: str year: int director: NotRequired[str] rating: NotRequired[float]The total parameter controls the default: total=False makes all keys optional by default, and Required marks individual keys as mandatory:
from typing import TypedDict, Required
class Movie(TypedDict, total=False): name: Required[str] year: int director: str rating: floatUnder the hood, the TypedDict metaclass computes __required_keys__ and __optional_keys__ as Frozensets at class creation time. These are runtime attributes:
Movie.__required_keys__ # frozenset({'name'})Movie.__optional_keys__ # frozenset({'year', 'director', 'rating'})Nested TypedDicts
Section titled “Nested TypedDicts”TypedDicts compose. Real-world data is often deeply nested (JSON API responses, configuration Files), and nested TypedDicts model this structure precisely:
from typing import TypedDict, NotRequired
class Address(TypedDict): street: str city: str zipcode: str country: str
class Person(TypedDict): name: str age: int address: Address phone: NotRequired[str]Runtime isinstance Checks
Section titled “Runtime isinstance Checks”isinstance(obj, MyTypedDict) does not work. TypedDict is a structural type that is erased at Runtime — a regular dict does not carry the TypedDict type information. To check whether a Dictionary conforms to a TypedDict shape, you need a runtime validation library like pydantic msgspecOr cattrs.
You can, however, use typing.get_type_hints() to introspect the TypedDict’s field types at Runtime, which is useful for building custom validation:
from typing import TypedDict, get_type_hints
class Config(TypedDict): host: str port: int debug: bool
hints = get_type_hints(Config)## {'host': <class 'str'>, 'port': <class 'int'>, 'debug': <class 'bool'>}JSON Deserialization
Section titled “JSON Deserialization”TypedDicts are the natural target for JSON deserialization. A JSON object maps directly to a dict[str, ...]And a TypedDict constrains which keys and value types are expected:
import jsonfrom typing import TypedDict
class User(TypedDict): id: int name: str email: str
raw = '{"id": 1, "name": "Alice", "email": "alice@example.com"}'user: User = json.loads(raw)Note: json.loads returns a plain dict at runtime. The type annotation user: User is a type Checker assertion, not a runtime guarantee. If the JSON is malformed (missing keys, wrong types), The type checker will not catch it — you need runtime validation for that.
TypeVarTuple (PEP 646)
Section titled “TypeVarTuple (PEP 646)”TypeVarTuple introduces variadic generics to Python’s type system. It allows you to define types That operate over a variable number of type parameters, analogous to how *args captures a variable Number of positional arguments at runtime.
Motivation
Section titled “Motivation”Consider a function that takes two arrays of the same length and returns an array of pairs:
def zip_arrays(a: list[T], b: list[T]) -> list[tuple[T, T]]: return list(zip(a, b))This works for two arrays. But what if you want to zip three, four, or N arrays, all with the same Element type? Or what if you want to preserve the types of each array independently? Without Variadic generics, you would need to write a separate overload for each arity.
Syntax and Usage
Section titled “Syntax and Usage”from typing import TypeVarTuple, Generic
Ts = TypeVarTuple("Ts")
class Array(Generic[*Ts]): def __init__(self, *shapes: *Ts) -> None: self.shapes = shapesThe *Ts in Generic[*Ts] means “accept any number of type parameters.” The *shapes: *Ts in the Method signature means “accept a variadic number of arguments, each with one of the types in Ts.”
Mapping Over Variadic Args
Section titled “Mapping Over Variadic Args”A common pattern is using TypeVarTuple with map or zip to operate over arrays of different Types:
from typing import TypeVarTuple
Ts = TypeVarTuple("Ts")
def concatenate(*arrays: *tuple[*Ts]) -> tuple[*Ts]: return arraysUse Cases
Section titled “Use Cases”- Array/tensor operations: Libraries like NumPy and JAX benefit from variadic generics because array shapes are multi-dimensional tuples of integers.
TypeVarTuplecan express “an N-dimensional array” asArray[*Shape]whereShapeis a variadic tuple of integers. - Matrix operations: You can express “a matrix of shape (M, N)” without hardcoding the number of dimensions.
- Protocol-level generics: When defining decorators or middleware that need to preserve the full signature of the wrapped function, including the types of all positional arguments.
Limitations
Section titled “Limitations”TypeVarTuplesupport varies across type checkers. Mypy added support in mypy 1.0+, pyright supports it. Older type checkers will not understand it.- You cannot have more than one
TypeVarTuplein the same generic parameter list (in most type checkers), because unpacking multiple variadic sequences creates ambiguity. - The runtime behavior is unchanged —
TypeVarTupleis purely a type-system construct.
ParamSpec
Section titled “ParamSpec”ParamSpec (PEP 612) captures the full parameter signature of a callable as a single type variable. This is essential for typing decorators that preserve the wrapped function’s signature.
The Problem
Section titled “The Problem”Before ParamSpecTyping decorators was fundamentally limited. A decorator that wraps a function And returns another function would either lose the original signature or require complex @overload Chains:
from typing import Callable, TypeVar
F = TypeVar("F", bound=Callable[..., object])
def log(func: F) -> F: def wrapper(*args, **kwargs): print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapper # type checkers cannot verify this preserves the signatureThe Callable[..., object] signature is opaque — it tells the type checker nothing about the Argument types or return type. ParamSpec solves this by letting you capture and replay the full Signature.
Syntax
Section titled “Syntax”from typing import ParamSpec, TypeVar
P = ParamSpec("P")R = TypeVar("R")
def log(func: Callable[P, R]) -> Callable[P, R]: def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapperP captures the complete parameter specification of func: positional parameters, keyword Parameters, their types, defaults, and whether they can be passed by position or keyword. P.args And P.kwargs are synthetic types that let you express “accepts the same positional and keyword Arguments as P.”
Concatenate[P, T] — Appending Parameters
Section titled “Concatenate[P, T] — Appending Parameters”Some decorators need to add parameters to the wrapped function’s signature. Concatenate lets you Prepend or append type parameters to a ParamSpec:
from typing import ParamSpec, TypeVar, Callable, Concatenate
P = ParamSpec("P")R = TypeVar("R")
def with_retry(max_retries: int): def decorator(func: Callable[P, R]) -> Callable[Concatenate[int, P], R]: def wrapper(retries: int, *args: P.args, **kwargs: P.kwargs) -> R: for attempt in range(retries): try: return func(*args, **kwargs) except Exception: if attempt == retries - 1: raise raise RuntimeError("unreachable") return wrapper return decoratorThe Concatenate[int, P] signature means “the decorated function takes an int (the retry count) Followed by whatever parameters the original function accepted.”
Use Cases
Section titled “Use Cases”- Decorators: Any decorator that wraps a function and returns a wrapper.
ParamSpecensures the type checker knows the wrapper accepts the same arguments as the original. - Higher-order functions: Functions that accept callbacks, like
map``filter``sorted(key=)or retry decorators. - Dependency injection frameworks: When the framework needs to accept a user-defined function and inject additional parameters at call time.
TypeGuard and TypeIs
Section titled “TypeGuard and TypeIs”TypeGuard and TypeIs are special forms for user-defined type narrowing functions. They let you Tell the type checker “if this function returns TrueThe argument is of type T.”
TypeGuard (Python 3.10+)
Section titled “TypeGuard (Python 3.10+)”TypeGuard is the older form (introduced in PEP 647). It narrows the type of the argument in the True branch but does not narrow in the False branch:
from typing import TypeGuard
def is_list_of_strs(value: object) -> TypeGuard[list[str]]: return isinstance(value, list) and all(isinstance(x, str) for x in value)
data: object = get_data()if is_list_of_strs(data): reveal_type(data) # list[str] -- narrowed in True branchelse: reveal_type(data) # object -- NOT narrowed in False branchThe critical asymmetry: TypeGuard allows widening in the True branch. That is, the narrowed type Does not have to be a subtype of the input type. For example, a function that checks whether a String is actually a valid JSON document could return TypeGuard[dict[str, object]] even though the Input type is str. This makes TypeGuard flexible but also potentially unsound — the type Checker trusts your assertion without verification.
TypeIs (Python 3.13+, typing_extensions)
Section titled “TypeIs (Python 3.13+, typing_extensions)”TypeIs (PEP 742) is stricter: the return type must be a subtype of (or equal to) the input type, And it narrows in both the True and False branches:
from typing import TypeIs
def is_str(value: object) -> TypeIs[str]: return isinstance(value, str)
data: object = get_data()if is_str(data): reveal_type(data) # strelse: reveal_type(data) # object (but known to NOT be str)When to use which:
| Property | TypeGuard | TypeIs |
|---|---|---|
Narrows in True branch | Yes | Yes |
Narrows in False branch | No | Yes |
| Allows widening | Yes | No |
| Soundness | Trusts you | Checked |
Use TypeIs by default when the narrowed type is a subtype of the input. Use TypeGuard only when You need to widen (e.g., parsing a string into a structured type).
@overload Pattern with TypeGuard
Section titled “@overload Pattern with TypeGuard”You can combine @overload with TypeGuard to provide different return types based on input:
from typing import overload, TypeGuard, Union
@overloaddef is_int_or_str(x: int) -> TypeIs[int]: ...@overloaddef is_int_or_str(x: str) -> TypeIs[str]: ...@overloaddef is_int_or_str(x: object) -> TypeIs[Union[int, str]]: ...
def is_int_or_str(x: object) -> bool: return isinstance(x, (int, str))Self Types
Section titled “Self Types”Methods that return self (for method chaining) or return instances of the same class as the Receiver need a way to express “the type of the current class, whatever subclass it may be.”
The Problem
Section titled “The Problem”class Builder: def set_name(self, name: str) -> Builder: self.name = name return self
class FancyBuilder(Builder): def set_style(self, style: str) -> FancyBuilder: self.style = style return self
b = FancyBuilder()result = b.set_name("test").set_style("fancy") # type error!The type error occurs because set_name is annotated to return BuilderNot FancyBuilder. The Type checker sees Builder.set_name(...) -> BuilderAnd Builder has no set_style method.
Python 3.11+: typing.Self
Section titled “Python 3.11+: typing.Self”PEP 673 introduced typing.Self (available in Python 3.11+ from typingAnd earlier from typing_extensions):
from typing import Self
class Builder: def set_name(self, name: str) -> Self: self.name = name return selfSelf is always exactly the type of the class in which the method is defined, including any Subclasses. It works in class methods, instance methods, and __new__.
Legacy: TypeVar-based approach
Section titled “Legacy: TypeVar-based approach”Before Self was available, the pattern was:
from typing import TypeVar
T = TypeVar("T", bound="Builder")
class Builder: def set_name(self: T, name: str) -> T: self.name = name return selfThis works but is verbose, error-prone (you must remember to use the TypeVar consistently), and Does not work correctly for __init_subclass__ or class methods in all type checkers. Self is Strictly superior.
Final and ClassVar
Section titled “Final and ClassVar”ClassVar
Section titled “ClassVar”ClassVar marks an attribute as belonging to the class itself, not to instances:
from typing import ClassVar
class Database: connection_pool_size: ClassVar[int] = 10 _instance: ClassVar["Database | None"] = None
def __init__(self, host: str) -> None: self.host = hostClassVar tells the type checker: “this attribute is set on the class, not on self.” This means:
db.connection_pool_sizeis valid (access on the class).db.connection_pool_sizeis also valid on an instance (Python looks up the attribute on the class when it is not found on the instance).- Assigning to
self.connection_pool_size = 20inside a method would create an instance attribute that shadows the class attribute. The type checker will flag this if the field is annotated withClassVar.
ClassVar does not create a descriptor or enforce anything at runtime. It is purely a type-system Annotation.
Final indicates that a name should not be reassigned, overridden, or modified:
from typing import Final
MAX_RETRIES: Final[int] = 3API_VERSION: Final = "v1"At the module level, Final tells the type checker that the name is a constant. Any reassignment Will be flagged as a type error.
At the class level, Final prevents subclasses from overriding the attribute:
from typing import Final
class Base: timeout: Final[int] = 30
class Derived(Base): timeout = 60 # type error: cannot override Final attributeFinal also works with ClassVar:
from typing import ClassVar, Final
class Config: MAX_CONNECTIONS: ClassVar[Final[int]] = 100This means: the attribute is a class variable, it is an integer, and it must not be reassigned.
Runtime Behavior
Section titled “Runtime Behavior”Neither ClassVar nor Final enforce anything at runtime. You can reassign a Final variable or Create an instance attribute shadowing a ClassVar attribute, and Python will not raise an error. These annotations are for static analysis only.
Recursive Types
Section titled “Recursive Types”Recursive types are types that refer to themselves. They are essential for modeling tree structures, Linked lists, JSON-like nested data, and any data structure with arbitrary depth.
Forward References
Section titled “Forward References”Before Python 3.11, you needed string quotes for forward references when a type refers to a class That has not been defined yet:
from typing import Optional
class Node: def __init__(self, value: int) -> None: self.value = value self.left: Optional["Node"] = None self.right: Optional["Node"] = NoneThe quotes around "Node" tell the type checker (and the Python parser) to treat this as a deferred Annotation. At class definition time, Node does not exist yet (the class body is still executing), So the string form is necessary.
from __future__ import annotations (PEP 563)
Section titled “from __future__ import annotations (PEP 563)”This future import changes how all annotations are evaluated. Instead of evaluating them at Definition time, Python stores them as strings:
from __future__ import annotationsfrom typing import Optional
class Node: def __init__(self, value: int) -> None: self.value = value self.left: Optional[Node] = None # No quotes needed self.right: Optional[Node] = NoneWith from __future__ import annotationsall annotations become strings automatically. This Eliminates the need for quotes in forward references, but it changes how annotations are accessed at Runtime: you must call typing.get_type_hints() to resolve them, rather than reading __annotations__ directly (which will contain raw strings).
JSON-like Recursive Structures
Section titled “JSON-like Recursive Structures”A common use case for recursive types is modeling JSON data, which can be arbitrarily nested:
from typing import Union
JSONValue = Union[None, bool, int, float, str, list["JSONValue"], dict[str, "JSONValue"]]
def process(data: JSONValue) -> None: ...The type checker resolves the forward reference "JSONValue" by looking it up after the full module Has been processed. This works because type checkers perform multiple passes over the source.
Tree Types
Section titled “Tree Types”from typing import Generic, TypeVar, Optional
T = TypeVar("T")
class Tree(Generic[T]): value: T children: list["Tree[T]"]
def __init__(self, value: T, children: list["Tree[T]] | None = None) -> None: self.value = value self.children = children or []Type Narrowing
Section titled “Type Narrowing”Type narrowing is the process by which a type checker reduces (narrows) the type of a variable based On control flow. Understanding exactly what triggers narrowing is essential for writing type-checked Code that compiles cleanly.
Mechanisms
Section titled “Mechanisms”isinstance and issubclass:
def process(value: int | str) -> str: if isinstance(value, int): return str(value) # value is narrowed to int return value # value is narrowed to strTuple form of isinstance:
def process(value: int | str | float) -> str: if isinstance(value, (int, str)): return str(value) # value is narrowed to int | str return str(value) # value is narrowed to floatis None / is not None:
def process(value: str | None) -> str: if value is None: return "default" return value # value is narrowed to strNote: use is None / is not NoneNot == None. Some type checkers do not narrow on == None Because __eq__ can be overridden to return arbitrary results. is None checks identity and cannot Be overridden.
Truthiness narrowing:
def process(value: str | None) -> str: if value: return value # value is narrowed to str (not None) return ""After a truthiness check, the type is narrowed to exclude NoneEmpty containers, zero, and False. The exact narrowing depends on the type: for str | NoneTruthiness narrows to str. For list[int] | NoneTruthiness narrows to list[int].
Length checks:
def process(items: list[int]) -> int: if len(items) > 0: return items[0] # OK: list is known to be non-empty raise ValueError("empty list")Some type checkers narrow list[int] to list[int] (no useful narrowing) while others narrow to a Non-empty list type. Check your type checker’s documentation for specifics.
assert:
def process(value: object) -> int: assert isinstance(value, int) return value # value is narrowed to intassert triggers narrowing just like if. Type checkers treat assert isinstance(x, T) as a Narrowing guard. Note that assert can be disabled at runtime with python -OSo do not rely on It for correctness — only for type narrowing and development-time checks.
Literal matching:
from typing import Literal
def process(action: Literal["create", "delete"]) -> None: if action == "create": reveal_type(action) # Literal["create"] else: reveal_type(action) # Literal["delete"]cast() — Explicit Type Assertion
Section titled “cast() — Explicit Type Assertion”typing.cast() tells the type checker “trust me, this value is of type T.” It performs no Runtime check. At runtime, cast is a no-op that returns its argument unchanged:
from typing import cast
def process(data: dict[str, object]) -> int: return cast(int, data["id"])If data["id"] is actually a string at runtime, cast will not raise an error. The program will Proceed with whatever value is there, potentially causing a downstream failure. cast is a tool for Last-resort cases where you know the type better than the type checker (e.g., after a runtime check That the type checker cannot understand, or when interfacing with untyped code).
Prefer isinstance checks over cast wherever possible. cast should be a controlled escape Hatch, not a default pattern.
Generic Classes
Section titled “Generic Classes”A generic class is parameterized by one or more type variables. This lets you create containers, Prototypes, and abstractions that are type-safe regardless of the concrete types they hold.
Basic Generic Class
Section titled “Basic Generic Class”from typing import Generic, TypeVar
T = TypeVar("T")
class Box(Generic[T]): def __init__(self, value: T) -> None: self._value = value
def get(self) -> T: return self._value
def set(self, value: T) -> None: self._value = valueAt runtime, Box[int] and Box[str] are the same class (Box). The type parameter exists only in The type system. This is called type erasure, and it is the same model used by Java’s generics.
Multiple Type Parameters
Section titled “Multiple Type Parameters”from typing import Generic, TypeVar
K = TypeVar("K")V = TypeVar("V")
class Pair(Generic[K, V]): def __init__(self, key: K, value: V) -> None: self.key = key self.value = valueBounded TypeVars
Section titled “Bounded TypeVars”TypeVar with bound restricts the type variable to a specific type or its subtypes:
from typing import TypeVar, Generic
class Animal: def speak(self) -> str: return "..."
T = TypeVar("T", bound=Animal)
class Shelter(Generic[T]): def __init__(self) -> None: self._animals: list[T] = []
def add(self, animal: T) -> None: self._animals.append(animal)
def first(self) -> T: return self._animals[0]Inside Shelter[T]``T is known to be Animal or a subclass, so you can call .speak() on any T value.
__class_getitem__
Section titled “__class_getitem__”When you write Box[int]Python calls Box.__class_getitem__(int). This is how subscripting a Class works at runtime. Generic[T] provides the default __class_getitem__ implementation. You Can override it for custom behavior, but this is rarely needed:
class Box: def __class_getitem__(cls, item): return f"{cls.__name__}[{item.__name__}]"
print(Box[int]) # Box[int] (a string, not a GenericAlias)Generic Methods
Section titled “Generic Methods”Individual methods can be generic even if the class itself is not:
from typing import TypeVar
T = TypeVar("T")
class Formatter: def format(self, value: T) -> str: return str(value)
def format_all(self, *values: T) -> list[str]: return [str(v) for v in values]Subclassing Generic Classes
Section titled “Subclassing Generic Classes”from typing import Generic, TypeVar
T = TypeVar("T")
class Box(Generic[T]): def __init__(self, value: T) -> None: self._value = value
def get(self) -> T: return self._value
class LockedBox(Box[T]): def __init__(self, value: T) -> None: super().__init__(value) self._locked = False
def lock(self) -> None: self._locked = True
def get(self) -> T: if self._locked: raise RuntimeError("box is locked") return super().get()LockedBox inherits the type parameter T from Box. LockedBox[int] is a Box[int]. The type Checker preserves the relationship.
Type Stubs (.pyi Files)
Section titled “Type Stubs (.pyi Files)”What They Are
Section titled “What They Are”A .pyi file is a type stub file. It contains only type annotations and no executable code. Type checkers use .pyi files as the source of type information for a module, preferentially over The corresponding .py file.
def connect(host: str, port: int, *, timeout: float = 30.0) -> Connection: ...def disconnect(conn: Connection) -> None: ...
class Connection: def send(self, data: bytes) -> int: ... def recv(self, bufsize: int) -> bytes: ... def close(self) -> None: ...The ... (ellipsis) is the required body for function and method stubs. It is a valid Python Expression that serves as a placeholder. Each stub is a complete function definition with type Annotations but no implementation.
When They Are Needed
Section titled “When They Are Needed”- C extensions: Modules written in C (
.so/.pydfiles) have no Python source code to annotate. The.pyifile is the only way to provide type information. - Compiled/generated modules: Code generated by tools (Protobuf, Thrift, Pydantic models) may produce Python files that are not meant to be edited by hand. Stubs provide a stable interface layer.
- Separating interface from implementation: In large codebases, you may want to publish type information without publishing the implementation. Stubs serve as a public API surface.
- Legacy code: When you cannot modify the source (third-party code, frozen dependencies), stubs let you add types without touching the implementation.
Discovery
Section titled “Discovery”Type checkers look for .pyi files using the same module resolution mechanism as Python’s import System. For a module foo.barThe type checker looks for:
foo/bar.pyi(stub file in the package directory)foo/bar.py(regular Python file, with inline annotations)foo/__pycache__/bar.pyi(not standard; some tools use this)
If both foo/bar.py and foo/bar.pyi exist, the type checker uses the .pyi file and ignores the .py file for type checking purposes. At runtime, Python still imports the .py file.
typeshed
Section titled “typeshed”typeshed (github.com/python/typeshed) is the repository of type stubs for the Python standard Library and selected third-party packages. It is the authoritative source for stdlib types and is Bundled with most type checkers.
When you import json and the type checker knows that json.loads returns AnyThat information Comes from typeshed/stdlib/json.pyi.
@typing.overload in Stubs
Section titled “@typing.overload in Stubs”Stubs frequently use @overload to document the multiple signatures of functions that accept Different argument types. This is especially common for built-in functions and stdlib functions that Have evolved over many Python versions:
# typeshed/stdlib/builtins.pyi (simplified)@overloaddef len(obj: Sized) -> int: ...@overloaddef len(obj: bytearray) -> int: ...Common Pitfalls
Section titled “Common Pitfalls”Using
Anyas a shortcut.Anydisables all type checking for the annotated value. Code typed asAnycan be used in any context without errors. This defeats the purpose of a type system. If you genuinely do not know the type, useUnknown(pyright) or add a# type: ignorecomment to acknowledge the gap explicitly.Anyshould be a deliberate choice, not a default.Confusing
Optional[X]with default arguments.Optional[X]means the value can beXorNone. It does not mean the function parameter has a default value. A parameter typed asOptional[str]with no default still requires an argument — the caller must explicitly passNoneor a string. Conversely, a parameter with a default value ofNoneshould be typed asOptional[str](orstr | None) to reflect that the default isNone.Using
Typeinstead oftype.typing.Type[X]and the builtintypeare different things.typeis a metaclass.Type[X]is a type annotation meaning “the class objectXor a subclass thereof.” In Python 3.9+, prefertype[X]overtyping.Type[X]for consistency with the lowercase convention introduced by PEP 585.Forgetting that
TypedDictis structural, not nominal. Two TypedDicts with the same keys and types are interchangeable to the type checker, even if they have different names. This is by design (structural subtyping), but it can be surprising:class User(TypedDict):name: strclass Employee(TypedDict):name: strdef greet(user: User) -> None: ...emp: Employee = {"name": "Alice"}greet(emp) # OK -- structural compatibilityUsing
cast()instead of proper narrowing.cast()is an escape hatch that silences the type checker without any runtime verification. Overusingcast()means you are fighting the type system instead of working with it. Everycast()is a potential bug if your assumption about the runtime type is wrong. Always tryisinstancechecks first.Ignoring variance in generic types.
TypeVarsupports three variance modes: covariant (covariant=True), contravariant (contravariant=True), and invariant (default). Getting variance wrong leads to type unsoundness. For example, aMutableSequencemust be invariant in its element type because you can both read from and write to it. Making it covariant would allow inserting aDoginto alist[Animal]that is actually alist[Cat]. If you are unsure, use invariant (the default).Relying on
from __future__ import annotationsfor runtime behavior. This import changes annotation storage to strings. If you use__annotations__at runtime to inspect types, you will get string representations instead of actual type objects. Always usetyping.get_type_hints()to resolve annotations whenfrom __future__ import annotationsis active.Type-checking only the happy path. If your function returns
int | NoneThe type checker will require you to handle theNonecase before using the value as anint. Do not work around this withcast(int, result)orassert result is not Nonewithout understanding the consequences. Let the type checker force you to handle edge cases — that is its job.Mixing
TypedDictwith regulardictannotations.dict[str, int]and a TypedDict withstrkeys andintvalues are structurally similar, but they are not the same type to all type checkers. Some type checkers treat them as compatible, others do not. Be explicit about which you mean.Overload order matters. Type checkers resolve overloads top-to-bottom, picking the first match. If a more general overload appears before a more specific one, the specific overload will never be matched. Always order overloads from most specific to most general.
TypeGuardvsTypeIsconfusion.TypeGuarddoes not narrow in theFalsebranch. If you write a function that checksisinstance(x, str)and returnTypeGuard[str]The type checker will narrow tostrin theTruebranch but leave it as the original type in theFalsebranch. UseTypeIswhen you need narrowing in both branches and the narrowed type is a subtype of the input.Generic
TypeVarwithout bound. An unboundedTypeVarcan be instantiated with any type, includingNone. If you writeT = TypeVar("T")and useTas a parameter type, the caller can passNoneunless you explicitly constrain it. Usebound=or explicit constraints if the type variable should be restricted.
Summary
Section titled “Summary”This topic covers the core concepts of advanced type system, including underlying theory, practical implementation, and key applications.
Key concepts include:
- CPU architecture and the fetch-decode-execute cycle
- memory hierarchy (cache, RAM, virtual)
- input/output systems
- operating systems and scheduling
- interrupts and polling
Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Intuition
Section titled “Intuition”Type hints in Python are like labeling boxes in a warehouse: they do not change what is inside, but they help you find things faster and notice when someone puts the wrong item in the wrong box. The @overload decorator is like having multiple different instruction manuals for the same machine, each describing what happens when you feed it a different material. Generics are templates that let you write one function that works for many types, the same way a cookie cutter makes the same shape regardless of which dough you use. Type guards are bouncers at a nightclub door, checking IDs and letting only the right types past.
Worked Examples
Section titled “Worked Examples”Example 1: Generic Protocol with Type Constraints
Section titled “Example 1: Generic Protocol with Type Constraints”Problem. Define a generic Sorter protocol that works with any comparable type, and implement a type-safe merge sort.
Solution.
from typing import Protocol, TypeVar, Sequence, list
T = TypeVar('T')
class Comparable(Protocol): def __lt__(self, other: T, /) -> bool: ... def __gt__(self, other: T, /) -> bool: ...
CT = TypeVar('CT', bound=Comparable)
def merge_sort(items: Sequence[CT]) -> list[CT]: if len(items) <= 1: return list(items) mid = len(items) // 2 left = merge_sort(items[:mid]) right = merge_sort(items[mid:]) return merge(left, right)
def merge(left: list[CT], right: list[CT]) -> list[CT]: result: list[CT] = [] i = j = 0 while i < len(left) and j < len(right): if left[i] < right[j]: result.append(left[i]) i += 1 else: result.append(right[j]) j += 1 result.extend(left[i:]) result.extend(right[j:]) return resultThe Protocol defines structural typing: any class with __lt__ and __gt__ satisfies Comparable without explicit inheritance. TypeVar('CT', bound=Comparable) constrains the generic to comparable types.
Example 2: Overload for Strict Type Narrowing
Section titled “Example 2: Overload for Strict Type Narrowing”Problem. Write a first function that returns T for non-empty sequences and None for empty ones, using @overload to express both return types.
Solution.
from typing import overload, Sequence, TypeVar
T = TypeVar('T')
@overloaddef first(seq: Sequence[T]) -> T: ...@overloaddef first(seq: Sequence[object]) -> T | None: ...
def first(seq: Sequence[T]) -> T | None: if not seq: return None return seq[0]The type checker selects the first overload when it can prove the sequence is non-empty (e.g., after a length check), returning T. Otherwise it falls back to T | None.
Summary
Section titled “Summary”@overloaddeclares multiple signatures for one function; the implementation handles all cases at runtime.Protocolenables structural subtyping: classes satisfy a protocol by having the required methods, without inheritance.TypeVarwithboundconstrains generics;TypeVarTupleandParamSpechandle variadic and callable generics.TypeGuardandTypeIsnarrow types in conditional branches for the type checker.dataclass_transformdecorates functions that create dataclass-like classes from type hints.
Cross-References
Section titled “Cross-References”- Functions, Closures, and Decorators — Type annotations on functions use the generics and overload patterns covered here.
- Generators and Iterators — Iterator and generator types are expressible using
Iterator[T]andGenerator[T]generics. - Protocols and Abstract Base Classes — Structural subtyping via Protocol extends the type system beyond nominal inheritance.
- Dataclasses and Attrs —
dataclass_transformbridges type hints and dataclass-style class creation.