typing Module Complexity¶
The typing module is where a program's type hints are built. Almost none of it does work at call
time — the cost lands when a module is imported, when a generic is parameterized, and when
something asks about the types afterwards.
Three sizes matter. k is the annotations on an object, p is the parameters in a subscription, and m is the members of a protocol. Subscription bounds assume ordinary type arguments with constant-time hashing and equality; p counts flattened arguments for unions.
The two operations worth knowing before you write anything: parameterizing a typing generic is
memoized, so a cache hit reuses List[int]; and isinstance() against a runtime-checkable
Protocol can walk its members even after earlier checks succeeded. From 3.12, successful
class-level structural checks have a constant-time cached path; instance data checks do not.
Complexity Reference¶
Introspection and helpers¶
| Operation | Time | Space | Notes |
|---|---|---|---|
typing.get_type_hints(obj) |
O(k·s) | O(k) | k = annotations, s = the source of each; string annotations are eval'd, and a class walks its whole MRO |
typing.get_origin(tp) |
O(1) | O(1) | Reads the origin of an already-built alias |
typing.get_args(tp) |
O(p) for explicit Callable parameters or Annotated metadata; otherwise O(1) |
O(p) for those allocating cases; otherwise O(1) | Reconstructs a parameter list or origin-and-metadata tuple; ordinary aliases reuse their argument tuple |
typing.cast(typ, val) |
O(1) | O(1) | Returns val itself; the type is never looked at |
typing.is_typeddict(tp), typing.is_protocol(tp) |
O(1) | O(1) | is_protocol is 3.13+ |
typing.get_protocol_members(tp) |
O(m) | O(m) | Python 3.13+; builds the member set |
typing.get_overloads(func), typing.clear_overloads() |
O(v) | O(v) | Python 3.11+; v = registered variants |
typing.assert_type(val, typ), typing.reveal_type(val) |
O(1) | O(1) | Python 3.11+; both return the value, reveal_type also writes to stderr |
typing.assert_never(arg) |
O(1) | O(1) | Python 3.11+; always raises |
typing.evaluate_forward_ref(ref) |
Depends on evaluation and resolved type | Depends on evaluation and resolved type | Python 3.14+; evaluates the expression and recursively traverses the resulting hint; source length alone gives no bound |
typing.no_type_check(arg), typing.no_type_check_decorator(dec) |
O(a) | O(1) | a = attributes on a class, each marked in turn |
Building parameterized types¶
| Operation | Time | Space | Notes |
|---|---|---|---|
typing.List[X], typing.Dict[K, V], typing.Type[X] |
O(1) | O(1) | Fixed arity; a cache hit reuses the alias |
typing.Tuple[...] |
O(p), including hits with fresh parameter tuples | O(p) on construction; O(1) auxiliary on a hit with an existing parameter tuple | From 3.14, reusing the same already-hashed tuple permits O(1) hits |
typing.Optional[X], typing.Union[X, Y] |
O(p) expected | O(p) | Ordinary hashable types use hash-based deduplication; from 3.14 the result is X \| Y and no longer memoized |
typing.Literal[...], typing.Annotated[X, ...] |
O(p) | O(p) | Literal deduplicates its values, Annotated keeps its metadata verbatim |
typing.Callable[[...], R], typing.Concatenate[...] |
O(p) | O(p) | p = parameter types |
typing.ClassVar[X], typing.Final[X], typing.Required[X], typing.NotRequired[X], typing.ReadOnly[X] |
O(1) | O(1) | Required/NotRequired are 3.11+, ReadOnly 3.13+ |
typing.TypeGuard[X], typing.TypeIs[X], typing.Unpack[X] |
O(1) | O(1) | TypeIs is 3.13+, TypeGuard 3.10+, Unpack 3.11+ |
Declaring types¶
| Operation | Time | Space | Notes |
|---|---|---|---|
typing.TypeVar(...), typing.TypeVarTuple(...), typing.ParamSpec(...) |
O(c) | O(c) | c = constraints or bounds; TypeVarTuple is 3.11+ |
typing.ParamSpecArgs, typing.ParamSpecKwargs |
O(1) | O(1) | The .args and .kwargs of a ParamSpec |
typing.NewType(name, tp) |
O(1) | O(1) | The result is a callable that returns its argument unchanged |
typing.NamedTuple, typing.TypedDict |
O(k) | O(k) | k = fields; one class is built per declaration, at import time |
typing.Generic, typing.Protocol |
O(p) | O(p) | Subclassing costs its parameters; see the protocol note below |
typing.TypeAlias, typing.TypeAliasType |
O(1) | O(1) | TypeAliasType is 3.12+ and evaluates its value lazily |
typing.ForwardRef(arg) |
Compilation cost on 3.10–3.13; O(1) from 3.14 | Compilation storage on 3.10–3.13; O(1) auxiliary from 3.14 | Older versions eagerly compile the source (at least a scan of its length); 3.14 retains the string and defers compilation |
Decorators¶
| Operation | Time | Space | Notes |
|---|---|---|---|
typing.overload(func) |
O(1) | O(1) | Registers the variant and returns a stub that raises if called |
typing.final(f), typing.override(m) |
O(1) | O(1) | Sets one attribute; override is 3.12+ |
typing.runtime_checkable(cls) |
O(1) before 3.12.2; O(m) from 3.12.2 | O(1) before 3.12.2; up to O(m) from 3.12.2 | Newer versions classify and retain non-method member names |
typing.dataclass_transform(...) |
O(1) | O(1) | Python 3.11+; sets one attribute for the type checker |
Markers with no parameters¶
| Operation | Time | Space | Notes |
|---|---|---|---|
typing.Any, typing.AnyStr, typing.NoReturn, typing.Never, typing.Self, typing.LiteralString, typing.NoDefault |
O(1) | O(1) | Module singletons; Never, Self and LiteralString are 3.11+, NoDefault 3.13+ |
typing.TYPE_CHECKING |
O(1) | O(1) | False at runtime, always |
Aliases of concrete and abstract collections¶
The collection aliases below are available at import time. Unparameterized aliases such as
List and Mapping support isinstance(); their parameterized forms, such as List[int]
and Mapping[str, int], raise TypeError in runtime checks.
| Operation | Time | Space | Notes |
|---|---|---|---|
typing.List, typing.Dict, typing.Set, typing.FrozenSet, typing.Tuple, typing.Type, typing.Text |
O(1) | O(1) | Deprecated since 3.9 in favour of list, dict, and friends |
typing.Deque, typing.DefaultDict, typing.OrderedDict, typing.ChainMap, typing.Counter |
O(1) | O(1) | The collections equivalents |
typing.AbstractSet, typing.MutableSet, typing.Mapping, typing.MutableMapping, typing.Sequence, typing.MutableSequence, typing.ByteString |
O(1) | O(1) | The collections.abc equivalents |
typing.MappingView, typing.ItemsView, typing.KeysView, typing.ValuesView |
O(1) | O(1) | The view types a mapping returns |
typing.Iterable, typing.Iterator, typing.Generator, typing.Reversible, typing.Container, typing.Collection, typing.Hashable, typing.Sized |
O(1) | O(1) | |
typing.Awaitable, typing.Coroutine, typing.AsyncIterable, typing.AsyncIterator, typing.AsyncGenerator, typing.ContextManager, typing.AsyncContextManager |
O(1) | O(1) | |
typing.Match, typing.Pattern |
O(1) | O(1) | The re result types |
typing.IO, typing.TextIO, typing.BinaryIO |
O(1) | O(1) | Generic classes rather than aliases |
typing.SupportsAbs, typing.SupportsBytes, typing.SupportsComplex, typing.SupportsFloat, typing.SupportsIndex, typing.SupportsInt, typing.SupportsRound |
O(1) | O(1) | One-method runtime-checkable protocols |
Subscription Is Memoized¶
For cacheable subscriptions such as List[int], a cache hit returns the same alias. Variable-arity
Tuple[...] still costs O(p) on a hit with a fresh parameter tuple. From 3.14, reusing
the same already-hashed tuple permits O(1) hits. The builtin generics — list[int],
dict[str, int] — do not share that cache and build a fresh alias each time.
from typing import Dict, List
# The same object, not merely an equal one
assert List[int] is List[int] # O(1) after the first
assert Dict[str, int] is Dict[str, int]
# The builtin form is not cached
assert list[int] is not list[int]
assert list[int] == list[int]
Union Flattens and Deduplicates¶
Union is not a plain container of its arguments: nested unions are flattened and duplicates
removed. For ordinary hashable types this takes expected O(p) time and O(p) space. Hash
collisions can require quadratic work; that is separate from the normal hash-based bound.
from typing import Optional, Union, get_args
assert Union[int, str, int] == Union[int, str] # deduplicated
assert Union[int, Union[str, float]] == Union[int, str, float] # flattened
assert Optional[int] == Union[int, None]
assert set(get_args(Union[int, str])) == {int, str} # O(1) to read back
Unions changed shape in Python 3.14
From 3.14, Union[int, str] produces the same object as int | str — a types.UnionType
rather than a typing.Union alias — and it is no longer memoized, so two identical unions are
equal but not identical. Compare unions with ==, never with is.
Runtime Protocol Checks¶
runtime_checkable lets isinstance() work against a Protocol. On Python 3.10 and 3.11 each
check walks the protocol's members with hasattr, so a wide protocol costs more than a narrow one.
From 3.12, a cached successful class-level structural check takes O(1). Protocols with instance
data members still inspect up to m attributes on each check, and unsuccessful class-level
checks can fall through to that member walk. These bounds assume constant-cost attribute access
and fixed inheritance depth.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class Handle:
def close(self) -> None: ...
assert isinstance(Handle(), Closeable) # O(m) before 3.12, O(1) cached from 3.12
assert not isinstance(object(), Closeable)
# Only a protocol marked runtime_checkable can be used this way
class Unmarked(Protocol):
def close(self) -> None: ...
try:
isinstance(Handle(), Unmarked)
except TypeError as error:
assert 'runtime_checkable' in str(error)
# A data member is not covered by that cache: the check reads the instance,
# so its cost follows the member count on every version
@runtime_checkable
class Sized2D(Protocol):
width: int
height: int
class Box:
def __init__(self):
self.width = self.height = 1
assert isinstance(Box(), Sized2D) # O(m), cached or not
isinstance checks members, not signatures
A runtime protocol check asks only whether the attributes exist. A close taking the wrong
arguments still passes, and issubclass() against a protocol with non-method members raises
TypeError.
Annotations Are Not Free Before 3.14¶
An annotation is an expression, and on Python 3.10 through 3.13 it is evaluated when the def
runs. A function with three parameterized hints costs about fifteen times a bare one to define —
once, at import. From 3.14, annotations are evaluated lazily and that gap nearly closes.
from typing import Dict, List, Optional
# Evaluated at definition time before 3.14, lazily from 3.14
def transform(rows: Dict[str, List[int]], limit: Optional[int]) -> List[int]:
return []
# Either way, calling it checks nothing
assert transform("not a dict", "not an int") == []
The two ways to avoid the definition-time cost on every supported version:
from __future__ import annotations # every annotation becomes a string
from typing import TYPE_CHECKING
# Imports needed only by hints can be skipped at runtime entirely
assert TYPE_CHECKING is False
if TYPE_CHECKING:
import decimal # never imported when the program runs
Reading Hints Back¶
get_type_hints() is the expensive introspection: it resolves every annotation, evaluating string
ones, and for a class it merges the annotations of the whole MRO.
from typing import Dict, List, get_args, get_origin, get_type_hints
def transform(rows: Dict[str, List[int]], limit: int) -> List[int]:
return []
hints = get_type_hints(transform) # O(k·s)
assert hints['limit'] is int
assert hints['return'] == List[int]
# Origin and ordinary-alias arguments are O(1); Callable/Annotated get_args can copy
assert get_origin(Dict[str, int]) is dict
assert get_args(Dict[str, int]) == (str, int)
Declaring Types¶
NamedTuple and TypedDict build a class per declaration, costing their field count once at
import. NewType is cheaper than it looks — the result is a function that returns its argument.
from typing import NamedTuple, NewType, TypedDict, is_typeddict
class Point(NamedTuple): # O(k) in fields, once at import
x: float
y: float
class Config(TypedDict): # O(k) in fields, once at import
name: str
retries: int
UserId = NewType('UserId', int) # O(1)
assert Point(1.0, 2.0).x == 1.0
assert is_typeddict(Config) is True
assert is_typeddict(Point) is False
assert UserId(7) == 7 # the call returns its argument unchanged
Casting Is a No-Op¶
cast() exists for the type checker. At runtime it returns the object it was given, without
looking at the type at all.
from typing import List, cast
values = [1, 2, 3]
assert cast(List[str], values) is values # O(1), and no conversion happens
assert cast('anything at all', values) is values
Version Notes¶
- Python 3.11+:
Self,Never,LiteralString,Required,NotRequired,TypeVarTuple,Unpack,assert_type,assert_never,reveal_type,dataclass_transform,get_overloads,clear_overloads - Python 3.12+:
override,TypeAliasType, and a constant-time path for cached successful class-level protocol checks - Python 3.13+:
TypeIs,ReadOnly,NoDefault,is_protocol,get_protocol_members - Python 3.14+:
evaluate_forward_ref; annotations are evaluated lazily, andUnion[X, Y]producesX | Yand is no longer memoized
Related Modules¶
- dataclasses - annotations that do build something at import
- collections - what the collection aliases point at
- abc - the registration mechanism behind
isinstanceon an ABC
Best Practices¶
✅ Do:
- Compare types with
==, notis— the memoization is an implementation detail, and 3.14 drops it for unions - Keep hint-only imports behind
TYPE_CHECKING - Call
get_type_hints()once and keep the result if you need it repeatedly - Use the builtin generics (
list[int]) in new code; thetypingaliases are deprecated
❌ Avoid:
- Repeated protocol checks in a hot loop when they must inspect members: on 3.10–3.11, and for instance data checks on newer versions too
- Treating a runtime protocol check as a signature check; it only looks for the names
get_type_hints()inside a request path — it re-resolves the whole MRO every call- Assuming annotations are free before 3.14