PYTHON / ADVANCED PYTHON
Typing: generics, Optional, and protocols
Write generic classes and functions with TypeVar, model absent values with Optional, and type by structure using Protocol.
What you will learn
- Declare a TypeVar and reuse it to link parameter and return types
- Subclass Generic[T] so Box[str] and Box[int] stay distinct to a checker
- Use Optional[T] plus an explicit `is None` guard to narrow the value
- Define a Protocol to accept any object with the right methods
Understanding Typing: generics, Optional, and protocols
A TypeVar is a placeholder that ties several positions in one signature together. When you write `def first(items: list[T], default: T) -> T`, you are not saying "any type" three times; you are saying all three must be the same type, resolved fresh at each call site. A checker binds T to int for `first([1, 2], 0)` and to str for `first(["a"], "z"), and rejects `first([1, 2], "z")` because T cannot be both. A class becomes generic the same way, by inheriting from `Generic[T]`, which makes `Box[str]` a valid annotation and lets the checker know that `Box[str].take()` yields a str.
`Optional[T]` is nothing more than shorthand for `Union[T, None]`, spelled `T | None` since Python 3.10. It describes the set of values that may appear, so a checker will refuse `value.upper()` on an `Optional[str]` until you eliminate None, usually with `if value is None: ...` or `if value is not None: ...`. That elimination is called narrowing: inside the guarded branch the type is plain str. Optional says nothing about whether an argument may be omitted at a call; only a default value does that.
A Protocol describes a shape rather than an ancestor. Declaring `class Closeable(Protocol): def close(self) -> None: ...` means any object with a matching `close` method satisfies it, with no registration and no base class, which is how you give static types to duck typing you already wrote. None of this changes runtime behaviour: annotations are ordinary objects stored on the function, and Python never checks them. If you want `isinstance` to work against a protocol you must decorate it with `@runtime_checkable`, and even then only attribute names are checked, not their signatures.
from typing import Generic, Optional, Protocol, TypeVar, runtime_checkable
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def add(self, item: T) -> None:
self._items.append(item)
def take(self) -> Optional[T]:
if not self._items:
return None
return self._items.pop()
runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class Socket:
def close(self) -> None:
print("socket closed")
def shut_down(resource: Closeable) -> None:
resource.close()
box: Box[str] = Box()
box.add("alpha")
print(box.take())
value = box.take()
print(value)
print(value.upper() if value is not None else "nothing left")
print(Box[str])
shut_down(Socket())
print(isinstance(Socket(), Closeable), isinstance(42, Closeable))Type hints describe relationships between types, either by binding a TypeVar across a signature or by describing an object's structure, and Python never enforces them at runtime.
Worked examples
One TypeVar, two call sites
Shows a TypeVar binding to a different type on each call while the runtime ignores the annotation entirely.
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T], default: T) -> T:
return items[0] if items else default
print(first([10, 20], 0))
print(first(["a"], "z"))
print(first([], 3.5))
print(first.__annotations__["return"])Example explained
Line 1`first([10, 20], 0)` binds T to int; the next call binds it to str, independently.
Line 2`first([], 3.5)` returns the default, and T is bound to float from that argument.
Line 3`first.__annotations__["return"]` prints `~T`, the repr of the TypeVar object: the hint is data, not a check.
Line 4Nothing here would fail at runtime even if you passed mismatched types; only a checker complains.
Optional and the falsy trap
Contrasts an explicit None check with `or`, which also swallows legitimate empty values.
from typing import Optional
def greet(name: Optional[str] = None) -> str:
if name is None:
name = "stranger"
return f"hello, {name}"
def tag(label: str | None) -> str:
return label or "untagged"
print(greet())
print(greet("ada"))
print(tag(None))
print(repr(tag("")))
print(Optional[str])Example explained
Line 1`= None` is what makes the argument omittable; `Optional[str]` only widens the accepted value type.
Line 2After `if name is None`, the checker narrows `name` to str, so the f-string is safe.
Line 3`tag("")` returns "untagged" because `or` treats the empty string as absent, which is a bug the annotation cannot catch.
Line 4`Optional[str]` prints as itself, confirming it is a runtime object equal to `Union[str, None]`.
Structural typing with Protocol
Demonstrates that a class satisfies a protocol without inheriting it, and that isinstance requires runtime_checkable.
from typing import Protocol, runtime_checkable
class Drawable(Protocol):
def draw(self) -> str: ...
runtime_checkable
class HasArea(Protocol):
def area(self) -> float: ...
class Circle:
def draw(self) -> str:
return "circle"
def area(self) -> float:
return 3.0
def render(shape: Drawable) -> str:
return shape.draw()
print(render(Circle()))
print(Drawable in Circle.__mro__)
print(isinstance(Circle(), HasArea))
try:
isinstance(Circle(), Drawable)
except TypeError as exc:
print("TypeError:", exc)Example explained
Line 1`Circle` never mentions `Drawable`, yet it satisfies it because it has a matching `draw` method.
Line 2`Drawable in Circle.__mro__` is False, proving the relationship is structural, not inheritance.
Line 3`isinstance(Circle(), HasArea)` works only because `HasArea` is decorated `@runtime_checkable`.
Line 4The same check against `Drawable` raises TypeError, since plain protocols are static-only.
Important notes
`@runtime_checkable` checks only that the attribute names exist; an object with a `close` attribute that is an int still passes isinstance.
Python 3.12 adds the `class Box[T]:` and `def first[T](...)` syntax, which creates the TypeVar implicitly but means exactly the same thing.
Common mistakes
Writing `def f(x: Optional[int])` and then calling `f()`: Optional does not add a default, so Python raises TypeError for the missing argument.
Using `if not value:` to rule out None on an `Optional[int]` or `Optional[str]`: 0 and "" are also falsy, so valid values silently take the None branch.
Calling `isinstance(obj, MyProtocol)` on a protocol that lacks `@runtime_checkable`: it raises TypeError instead of returning False.
Sharing one TypeVar across unrelated parameters that should differ, which forces a checker to unify them and rejects correct calls.
Try it yourself
Change, predict, then run
Write a generic `Pair(Generic[K, V])` class with `key: K`, `value: V`, and a `get(default: Optional[V]) -> Optional[V]` method, then add a `@runtime_checkable` protocol `Keyed` requiring a `key` attribute and print `isinstance(Pair("a", 1), Keyed)`.
Open the Python workspaceCheck your understanding
A function is defined as `def f(x: Optional[int]) -> int:` and called as `f()`. What happens?
- TypeError at call time, because Optional describes the value type and only a default value makes a parameter omittable
- x is bound to None automatically, since Optional implies a None default
- The call succeeds and returns None, because the annotation permits None
- It works at runtime but a type checker rejects the call as a missing default
Show answer
Annotations are never consulted when binding arguments, so a parameter with no default is required and `f()` raises TypeError for a missing positional argument. The tempting answer is that Optional implies a None default, but Optional[int] is just Union[int, None]; you must write `x: Optional[int] = None` to allow omission.