Generics & Protocols Interview Questions & Answers
6 questions Updated 2026-06-18
Python interview questions on TypeVar and Generic classes, bounded type variables, typing.Protocol structural typing, Callable types, and covariance vs invariance.
A TypeVar is a type variable — a placeholder that lets a function or class
work with any type while preserving it across inputs and outputs. Subclassing
Generic[T] turns a class into a generic container parameterized by that
variable, so a Stack[int] is known to hold and return ints.
from typing import TypeVar, Generic
T = TypeVar("T") # one placeholder type
def first(items: list[T]) -> T: # in and out share T
return items[0]
class Stack(Generic[T]): # generic class
def __init__(self) -> None:
self._items: list[T] = []
def push(self, x: T) -> None:
self._items.append(x)
def pop(self) -> T:
return self._items.pop()
s: Stack[int] = Stack()
s.push(1)
n = s.pop() # type checker knows n is int
Use a TypeVar whenever a relationship between argument and return types must be
captured — def first(items: list) -> object loses that, but list[T] -> T
keeps it. In Python 3.12+ you can write def first[T](items: list[T]) -> T
without the explicit TypeVar.
A plain TypeVar accepts any type. A bound (bound=...) restricts it to a
type and its subclasses, while constraints (TypeVar("T", int, str))
restrict it to a fixed set of specific types. Both let the body safely use the
capabilities implied by the bound.
from typing import TypeVar
class Animal:
def speak(self) -> str: ...
A = TypeVar("A", bound=Animal) # A must be Animal or a subclass
def loudest(animals: list[A]) -> A:
for a in animals:
a.speak() # OK — bound guarantees this method
return animals[0]
Num = TypeVar("Num", int, float) # constrained: ONLY int or float
def double(x: Num) -> Num:
return x * 2
Reach for a bound when "any subtype of X" is acceptable and the body needs X's interface; use constraints when only a handful of unrelated concrete types should be allowed.
A Protocol defines an interface by shape rather than inheritance: any
object that has the right methods/attributes is accepted, even if it never
explicitly subclasses the protocol. This is structural typing ("duck typing")
checked statically — "if it walks like a duck."
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def shutdown(resource: SupportsClose) -> None:
resource.close()
class File: # never inherits SupportsClose...
def close(self) -> None: ...
shutdown(File()) # ...but accepted: it has close()
Contrast with nominal typing (the usual class B(A)), where you must declare the
relationship. Protocols decouple the consumer from concrete classes — great for
typing third-party objects you can't modify.
By default a Protocol exists only for static checkers — isinstance() against
it raises TypeError. Decorating it with @runtime_checkable allows
isinstance() / issubclass() checks at runtime, but only for the presence of
the named methods, not their signatures or return types.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Sized(Protocol):
def __len__(self) -> int: ...
isinstance([1, 2, 3], Sized) # True — list has __len__
isinstance(42, Sized) # False — int has no __len__
It's a convenience, not a guarantee: the check confirms a method exists, not that
it takes the right arguments. Prefer static checking; use @runtime_checkable only
when you genuinely need a runtime branch.
Use Callable[[ArgTypes], ReturnType] from typing (or the built-in
collections.abc.Callable). The first element is the list of parameter types,
the second is the return type. Use ... for the parameters when you want to
accept any signature.
from collections.abc import Callable
def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
return fn(a, b)
apply(lambda x, y: x + y, 2, 3) # 5
handler: Callable[..., None] # any args, returns None
no_args: Callable[[], str] # takes nothing, returns str
For more precise signatures (preserving exact parameters of a wrapped function),
ParamSpec exists, but Callable[[...], R] covers the common cases. Type your
callbacks so the checker catches mismatched handlers.
Variance describes whether Container[Subtype] is usable where Container[Supertype]
is expected. Invariant (the default, e.g. list[T]): list[int] is not a
list[str] or a list[object]. Covariant: Tuple[int] is acceptable as
Tuple[object]. The intuition: mutable containers must be invariant for safety;
read-only ones can be covariant.
def total(nums: list[float]) -> float: ...
ints: list[int] = [1, 2]
total(ints) # type ERROR — list is invariant
from collections.abc import Sequence
def total2(nums: Sequence[float]) -> float: ...
total2(ints) # OK — Sequence is covariant (read-only)
Why mutables are invariant: if list[int] were a list[object], a function could
append a str to it, corrupting the original list[int]. Rule of thumb: accept
Sequence/Iterable (covariant, read-only) in parameters to be flexible; reserve
list/dict for when you truly need to mutate.
Practice tests are coming soon
Get notified when interactive mock interviews and quizzes launch.