Filter concepts by levelShowing all levels.

Python · Type Hints and Static Typing

Advanced typing

Concepts
4

A dict with a known shape, narrowing a Union after a check, writing your own generic class or function, and the substitution rule (variance) behind it. Protocol and structural typing — this subsection's other two roadmap items — are taught in Object-Oriented Python's Abstract classes and interfaces subsection; see that subsection's Duck typing and protocols group rather than duplicating it here.

This section

TypedDict and narrowing

A dict with a fixed, known shape, and shrinking a Union down to one branch after a check.

TypedDict

standardintermediate

TypedDict declares the exact keys a dict should have and the type of each value — for when a dict is really "a record with a fixed shape," not an arbitrary mapping. The result is still a plain dict at runtime.

Think of it as

A TypedDict is a printed form with labeled fields, not a class instance — {"title": ..., "year": ...} is still just a dict, but the TypedDict tells a type checker which keys must be there and what type belongs in each, the way a form tells you which boxes to fill and how.

python
from typing import TypedDict

class Movie(TypedDict):
    title: str
    year: int

m: Movie = {"title": "Arrival", "year": 2016}

What we're doing: Declare a TypedDict for a movie record and confirm the resulting value is still a plain dict at runtime.

movie.pypython
from typing import TypedDict

class Movie(TypedDict):
    title: str
    year: int

m: Movie = {"title": "Arrival", "year": 2016}
print(m, type(m))
3
class Movie(TypedDict): declares the required shape — every Movie must have exactly these keys.
7
m is built as a plain dict literal — TypedDict only affects how a type checker reads it, not the runtime type.
Output
{'title': 'Arrival', 'year': 2016} <class 'dict'>

Why this works: type(m) is dict, not Movie — TypedDict is purely a type-checking construct layered on top of the ordinary dict type. Movie(...) is never actually called as a constructor the way a class normally is; m is a plain dict literal that a type checker treats as matching (or not matching) the Movie shape.

Expecting TypedDict to validate required keys at runtime, like a dataclass would

Wrong

python
from typing import TypedDict

class Movie(TypedDict):
    title: str
    year: int

m: Movie = {"title": "Arrival"}  # missing "year" — no error at runtime!
print(m)

Better

python
# a type checker (mypy/pyright) flags the missing "year" key statically;
# for RUNTIME validation, use a library built for it, e.g. Pydantic's
# BaseModel, or write an explicit check:
def make_movie(title: str, year: int) -> Movie:
    return {"title": title, "year": year}   # forces both at the call site

What you see: A dict missing a required TypedDict key is built and used with no exception at all — the gap only shows up if a separate type checker run catches it.

Why: TypedDict, like every other typing construct, is read only by a static type checker. Python's dict literal syntax has no way to enforce "these keys are required" at runtime — that guarantee exists only in whatever tool reads the annotation, or in code you write yourself.

Remember: TypedDict declares a dict's expected keys and value types for a type checker. The result is a plain dict at runtime — nothing is validated when it runs.

See also: type aliases and annotated · generic collection annotations

Type narrowing and type guards

standardintermediate

Type narrowing is a type checker shrinking int | str down to just str inside an if isinstance(x, str): block. A type guard is a function you write yourself, marked TypeGuard[T], that teaches the checker a custom narrowing rule.

Think of it as

Narrowing is walking through a door that only fits one shape — outside, a value could be int or str; the isinstance(x, str) doorway only lets the str-shaped ones through, so everything after it is definitely str. A type guard is a custom-shaped doorway you design yourself, when isinstance alone cannot express the check you need.

python
from typing import TypeGuard

def describe(value: int | str) -> str:
    if isinstance(value, str):
        return value.upper()   # narrowed to str
    return value + 1           # narrowed to int

def is_str_list(vals: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(v, str) for v in vals)

What we're doing: Narrow a Union parameter with isinstance, and write a custom type guard that narrows a whole list at once.

narrowing.pypython
from typing import TypeGuard

def describe(value: int | str) -> str:
    if isinstance(value, str):
        return value.upper()   # value: str here
    return value + 1           # value: int here

def is_str_list(vals: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(v, str) for v in vals)

print(describe("hi"), describe(5))
print(is_str_list(["a", "b"]), is_str_list(["a", 1]))
4
A type checker narrows value from int | str to str for the rest of this if block, once isinstance confirms it.
6
By elimination, a type checker narrows value to int in the branch where the str check was False.
8
is_str_list actually checks every element at runtime — TypeGuard just tells the type checker to trust that check as narrowing.
Output
HI 6
True False

Why this works: describe("hi") calls value.upper() safely because isinstance already confirmed value is a str in that branch — a type checker would reject value.upper() without the check. is_str_list runs a real all(isinstance(...)) loop, so its bool answer is genuinely correct; TypeGuard[list[str]] just tells the type checker 'when this returns True, treat the list as list[str] from here on.'

Writing a TypeGuard function whose logic does not actually match what it claims to guard

Wrong

python
from typing import TypeGuard

def is_str_list(vals: list[object]) -> TypeGuard[list[str]]:
    return len(vals) > 0   # WRONG: doesn't actually check element types!

data: list[object] = [1, 2, 3]
if is_str_list(data):
    print(data[0].upper())  # type checker trusts this is safe -- it is NOT

Better

python
from typing import TypeGuard

def is_str_list(vals: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(v, str) for v in vals)  # actually checks every element

What you see: AttributeError: 'int' object has no attribute 'upper' at runtime — the type checker found nothing wrong, because it trusts a TypeGuard's claim rather than re-deriving it.

Why: A TypeGuard function is a promise you make to the type checker — it trusts your bool result completely and narrows accordingly, without re-verifying the logic itself. If the function's actual check does not match what TypeGuard[...] claims, the type checker's guarantee becomes wrong, and the mismatch only surfaces as a runtime crash.

A Union narrows to one branch after the check

int | str

value could be either

isinstance(x, str)

the check

x is str here

narrowed inside the if

  1. int | str — value could be either
  2. isinstance(x, str) — the check
  3. x is str here — narrowed inside the if

Remember: isinstance() inside an if narrows a Union automatically. TypeGuard[T] lets you write a custom narrowing function — its logic must actually match the claim.

See also: optional and union types · duck typing

Advertisement

Writing your own generics, and variance

Putting TypeVar/Generic to use in your own code, and the substitution rule that governs when one generic type can stand in for another.

Writing generic classes and functions

standardintermediate

A generic function uses the same TypeVar for a parameter and its return type, so a type checker ties the two together. A generic class does the same across its methods by inheriting Generic[T].

Think of it as

A generic function is a vending machine slot that returns whatever type of item you feed in the matching slot for — first(list[int]) hands back an int, first(list[str]) hands back a str, because the TypeVar links "what goes in" to "what comes out" every time, without writing a separate function per type.

python
from typing import TypeVar, Generic

T = TypeVar("T")

def first(items: list[T]) -> T:          # generic function
    return items[0]

class Stack(Generic[T]):                 # generic class
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, item: T) -> None:
        self._items.append(item)
    def pop(self) -> T:
        return self._items.pop()

What we're doing: Write a generic function that returns the first item of any list, and a generic Stack class that tracks the type of whatever it holds.

generics.pypython
from typing import TypeVar, Generic

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, item: T) -> None:
        self._items.append(item)
    def pop(self) -> T:
        return self._items.pop()

s: Stack[str] = Stack()
s.push("a")
s.push("b")
print(first([1, 2, 3]), s.pop())
5
first(items: list[T]) -> T: — the same T on both sides tells a type checker the return type matches the list's element type, whatever it is.
8
Stack(Generic[T]) makes the class generic — T is used consistently in __init__, push, and pop.
16
Stack[str] fixes T to str for this instance, so a type checker knows s.pop() returns a str.
Output
1 b

Why this works: first([1, 2, 3]) is inferred as returning int because a type checker unifies T with the list's element type at the call site — one function definition covers every element type without rewriting it. Stack[str] similarly fixes what the class holds for that instance, so s.pop() is known to return str, not "whatever."

Writing a "generic" function that does not actually use the TypeVar consistently

Wrong

python
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> str:   # return type doesn't use T at all!
    return items[0]

# a type checker now believes first() ALWAYS returns str,
# even when called with list[int]

Better

python
from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:     # T ties input and output together
    return items[0]

What you see: A type checker happily accepts first([1, 2, 3]) as returning str, then flags every actual use of that (wrongly typed) result as an error later on.

Why: A TypeVar only creates a real link between a parameter and a return type if the SAME TypeVar is used in both places. Declaring T but then hardcoding str as the return type breaks that link — the function is not actually generic, it just looks like it is from the signature alone.

Remember: A generic function ties a parameter's TypeVar to its return type. A generic class inherits Generic[T] and reuses T across its methods.

See also: typevar and generic · variance

Variance (conceptual)

referenceadvanced

Variance is whether Box[Dog] may be used where Box[Animal] is expected, given Dog is a subtype of Animal. Covariant allows it for read-only boxes; contravariant allows the reverse; invariant allows neither.

Think of it as

A read-only display case of Dog toys can safely stand in for "a display case of Animal toys" — you can only look, so a narrower stock is fine (covariant). A drop-box that only ACCEPTS Animal items cannot be replaced by one that only accepts Dog items — it would reject a Cat a caller was allowed to drop in (contravariant would need the reverse substitution). A box you both read AND write must match exactly, no substitution either way (invariant).

python
from typing import TypeVar, Generic

T_co = TypeVar("T_co", covariant=True)

class ReadOnlyBox(Generic[T_co]):
    def get(self) -> T_co: ...

What we're doing: Confirm that Python's runtime places no restriction at all on assigning a ReadOnlyBox[Dog] where ReadOnlyBox[Animal] is annotated — variance is purely a static-type-checker rule.

variance.pypython
from typing import TypeVar, Generic

T_co = TypeVar("T_co", covariant=True)

class ReadOnlyBox(Generic[T_co]):
    def __init__(self, item: T_co) -> None:
        self._item = item
    def get(self) -> T_co:
        return self._item

class Animal: pass
class Dog(Animal): pass

def print_animal(box: "ReadOnlyBox[Animal]") -> None:
    print(type(box.get()).__name__)

dog_box: ReadOnlyBox[Dog] = ReadOnlyBox(Dog())
print_animal(dog_box)
3
covariant=True is what a type checker reads to allow passing ReadOnlyBox[Dog] where ReadOnlyBox[Animal] is expected.
17
Python runs this with no error regardless of the covariant flag — variance is a compile-time-only concept, never checked while running.
Output
Dog

Why this works: Passing dog_box (a ReadOnlyBox[Dog]) into a function expecting ReadOnlyBox[Animal] works at runtime unconditionally — Python has no concept of variance at all. A type checker is what decides whether to ACCEPT or REJECT that call statically, based on whether T_co was declared covariant; the runtime behaviour is identical either way.

Remember: Covariant = safe for read-only substitution. Contravariant = the reverse, for write-only. Invariant = no substitution — Python enforces none of this at runtime.

See also: typevar and generic · writing generic classes and functions

Advertisement