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.