Python Generics and TypeVar Practice Problems & Exercises
Practice: Generics and TypeVar
← Back to lessonEasy
Write a generic identity function using TypeVar that returns its argument unchanged and preserves the exact type through the signature.
from typing import TypeVar
T = TypeVar("T")
def identity(x: T) -> T:
return x
# Each call preserves its specific type
print(identity(42))
print(identity("hello"))
print(identity([1, 2, 3]))Expected Output
42
hello
[1, 2, 3]Hints
Hint 1: Declare T = TypeVar("T") and annotate the function as def identity(x: T) -> T.
Hint 2: TypeVar ensures the return type matches the input type — the static checker enforces this even though runtime is identical.
Implement a Stack[T] generic class with push, pop, and peek operations. Demonstrate usage with integers.
from typing import TypeVar, Generic, List
T = TypeVar("T")
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:
if not self._items:
raise IndexError("pop from empty stack")
return self._items.pop()
def peek(self) -> T:
if not self._items:
raise IndexError("peek at empty stack")
return self._items[-1]
def __len__(self) -> int:
return len(self._items)
s: Stack[int] = Stack()
s.push(1)
s.push(2)
s.push(3)
print(f"Popped: {s.pop()}")
print(f"Remaining: {s._items}")
print(f"Top: {s.peek()}")Expected Output
Popped: 3
Remaining: [1, 2]
Top: 2Hints
Hint 1: Declare T = TypeVar("T") and class Stack(Generic[T]). Methods push, pop, and peek use T in their annotations.
Hint 2: The internal storage is a plain list; Generic[T] only affects static type checking.
Write generic first() and last() functions that work on any Sequence[T] and return Optional[T].
from typing import TypeVar, Sequence, Optional
T = TypeVar("T")
def first(seq: Sequence[T]) -> Optional[T]:
return seq[0] if seq else None
def last(seq: Sequence[T]) -> Optional[T]:
return seq[-1] if seq else None
nums = [10, 20, 30]
print(f"first([10,20,30]) = {first(nums)}")
print(f"last([10,20,30]) = {last(nums)}")
print(f"first([]) = {first([])}")
print(f"last([]) = {last([])}")Expected Output
first([10,20,30]) = 10
last([10,20,30]) = 30
first([]) = None
last([]) = NoneHints
Hint 1: Use TypeVar T and Sequence[T] as the parameter type. Return Optional[T].
Hint 2: Return None (typed as T | None) when the sequence is empty.
Medium
Use a bounded TypeVar to write sum_all and clamp functions that work on any numeric type (int or float) but not on strings or other types.
from typing import TypeVar, List
N = TypeVar("N", int, float)
def sum_all(values: List[N]) -> N:
result = values[0]
for v in values[1:]:
result = result + v
return result
def clamp(value: N, lo: N, hi: N) -> N:
if value < lo:
return lo
if value > hi:
return hi
return value
print(f"sum_all([1, 2, 3, 4]) = {sum_all([1, 2, 3, 4])}")
print(f"sum_all([1.5, 2.5, 3.0]) = {sum_all([1.5, 2.5, 3.0])}")
print(f"clamp(15, lo=0, hi=10) = {clamp(15, 0, 10)}")
print(f"clamp(-5, lo=0, hi=10) = {clamp(-5, 0, 10)}")Expected Output
sum_all([1, 2, 3, 4]) = 10
sum_all([1.5, 2.5, 3.0]) = 7.0
clamp(15, lo=0, hi=10) = 10
clamp(-5, lo=0, hi=10) = 0Hints
Hint 1: Declare N = TypeVar("N", int, float) or N = TypeVar("N", bound=numbers.Real) to constrain to numeric types.
Hint 2: Bounded TypeVars restrict which concrete types are allowed at call sites — operations like + and > are safe within the bound.
Build a Pair[A, B] generic class with bimap (transform both sides independently) and swap (flip first and second).
from typing import TypeVar, Generic, Callable, Tuple
A = TypeVar("A")
B = TypeVar("B")
C = TypeVar("C")
D = TypeVar("D")
class Pair(Generic[A, B]):
def __init__(self, first: A, second: B) -> None:
self.first = first
self.second = second
def bimap(self, f: Callable[[A], C], g: Callable[[B], D]) -> "Pair[C, D]":
return Pair(f(self.first), g(self.second))
def swap(self) -> "Pair[B, A]":
return Pair(self.second, self.first)
def __repr__(self) -> str:
return f"Pair({self.first!r}, {self.second!r})"
p: Pair[int, str] = Pair(1, "hello")
print(p)
transformed = p.bimap(lambda x: x * 2, str.upper)
print(f"After bimap: {transformed}")
swapped = p.swap()
print(f"Swapped: {swapped}")Expected Output
Pair(1, 'hello')
After bimap: Pair(2, 'HELLO')
Swapped: Pair('hello', 1)Hints
Hint 1: Use two TypeVars: A and B. Declare class Pair(Generic[A, B]) with a first: A and second: B attribute.
Hint 2: bimap takes two callables and returns a new Pair with both sides transformed.
Create a ReadOnlyBox[T_co] covariant container. Demonstrate that a ReadOnlyBox[str] can be used where a ReadOnlyBox[object] is expected.
from typing import TypeVar, Generic
T_co = TypeVar("T_co", covariant=True)
class ReadOnlyBox(Generic[T_co]):
def __init__(self, value: T_co) -> None:
self._value = value
def get(self) -> T_co:
return self._value
def __repr__(self) -> str:
return f"ReadOnlyBox({self._value!r})"
def print_any_box(box: "ReadOnlyBox[object]") -> None:
print(f"Contents: {box.get()}")
str_box: ReadOnlyBox[str] = ReadOnlyBox("hello")
print(f"ReadOnly[str] can hold a str: {str_box.get()}")
# Covariant: ReadOnlyBox[str] is assignable to ReadOnlyBox[object]
obj_box: ReadOnlyBox[object] = str_box # valid due to covariance
print(f"Covariant: ReadOnly[str] is assignable to ReadOnly[object]: {isinstance(obj_box, ReadOnlyBox)}")
print_any_box(str_box)Expected Output
ReadOnly[str] can hold a str: hello
Covariant: ReadOnly[str] is assignable to ReadOnly[object]: True
Contents: helloHints
Hint 1: Declare T_co = TypeVar("T_co", covariant=True) and use it in a read-only container (no __setitem__ or append).
Hint 2: A covariant container of str is a subtype of a container of object — this is safe because you can only read from it.
Implement a Result[T, E] algebraic type with Ok and Err variants. Add map, unwrap, and unwrap_or methods.
from typing import TypeVar, Generic, Callable, Union
T = TypeVar("T")
E = TypeVar("E")
U = TypeVar("U")
class Result(Generic[T, E]):
pass
class Ok(Result[T, E]):
def __init__(self, value: T) -> None:
self._value = value
def map(self, f: Callable[[T], U]) -> "Ok[U, E]":
return Ok(f(self._value))
def unwrap(self) -> T:
return self._value
def unwrap_or(self, default: T) -> T:
return self._value
def __repr__(self) -> str:
return f"Ok({self._value!r})"
class Err(Result[T, E]):
def __init__(self, error: E) -> None:
self._error = error
def map(self, f: Callable[[T], U]) -> "Err[U, E]":
return self # type: ignore
def unwrap(self) -> T:
raise ValueError(f"Called unwrap on Err: {self._error}")
def unwrap_or(self, default: T) -> T:
return default
def __repr__(self) -> str:
return f"Err({self._error!r})"
def safe_divide(a: int, b: int) -> Result[int, str]:
if b == 0:
return Err("division by zero")
return Ok(a // b)
ok_result = safe_divide(42, 1)
err_result = safe_divide(10, 0)
print(ok_result)
print(err_result)
print(f"map Ok: {ok_result.map(lambda x: x * 2)}")
print(f"map Err: {err_result.map(lambda x: x * 2)}")
print(f"unwrap_or: {err_result.unwrap_or(0)}")Expected Output
Ok(42)
Err('division by zero')
map Ok: Ok(84)
map Err: Err('division by zero')
unwrap_or: 0Hints
Hint 1: Create Ok[T] and Err[E], both subclasses of Result[T, E]. Use two TypeVars T and E.
Hint 2: Implement map(f) on Ok to transform the value; on Err it returns self unchanged. unwrap_or returns the value or the default.
Hard
Build a generic BST[T] (Binary Search Tree) with insert, contains, and in_order_traversal. The element type must be orderable.
from typing import TypeVar, Generic, Optional, List
T = TypeVar("T")
class BSTNode(Generic[T]):
def __init__(self, value: T) -> None:
self.value = value
self.left: Optional["BSTNode[T]"] = None
self.right: Optional["BSTNode[T]"] = None
class BST(Generic[T]):
def __init__(self) -> None:
self._root: Optional[BSTNode[T]] = None
def insert(self, value: T) -> None:
def _insert(node: Optional[BSTNode[T]], val: T) -> BSTNode[T]:
if node is None:
return BSTNode(val)
if val < node.value: # type: ignore[operator]
node.left = _insert(node.left, val)
elif val > node.value: # type: ignore[operator]
node.right = _insert(node.right, val)
return node
self._root = _insert(self._root, value)
def contains(self, value: T) -> bool:
node = self._root
while node:
if value == node.value:
return True
elif value < node.value: # type: ignore[operator]
node = node.left
else:
node = node.right
return False
def in_order(self) -> List[T]:
result: List[T] = []
def _walk(node: Optional[BSTNode[T]]) -> None:
if node:
_walk(node.left)
result.append(node.value)
_walk(node.right)
_walk(self._root)
return result
def min_val(self) -> Optional[T]:
node = self._root
while node and node.left:
node = node.left
return node.value if node else None
def max_val(self) -> Optional[T]:
node = self._root
while node and node.right:
node = node.right
return node.value if node else None
tree: BST[int] = BST()
for v in [5, 3, 7, 1, 9]:
tree.insert(v)
print(f"In-order: {tree.in_order()}")
print(f"Min: {tree.min_val()}")
print(f"Max: {tree.max_val()}")
print(f"Contains 5: {tree.contains(5)}")
print(f"Contains 6: {tree.contains(6)}")Expected Output
In-order: [1, 3, 5, 7, 9]
Min: 1
Max: 9
Contains 5: True
Contains 6: FalseHints
Hint 1: Declare T = TypeVar("T") with bound="Comparable" or just trust that elements are orderable. Use Optional[BSTNode[T]] for left/right.
Hint 2: insert and contains use comparison operators (<, >) which are available on the bounded type.
Build a type-safe Pipeline[A, B] that chains transformations. Composing pipe(f) on a Pipeline[A, B] with f: B -> C produces a Pipeline[A, C].
from typing import TypeVar, Generic, Callable, List, Any
A = TypeVar("A")
B = TypeVar("B")
C = TypeVar("C")
class Pipeline(Generic[A, B]):
def __init__(self, steps: List[Callable[..., Any]]) -> None:
self._steps = steps
@classmethod
def start(cls) -> "Pipeline[A, A]":
return cls([])
def pipe(self, f: Callable[[B], C]) -> "Pipeline[A, C]":
return Pipeline(self._steps + [f])
def run(self, value: A) -> B:
result: Any = value
for step in self._steps:
result = step(result)
return result # type: ignore[return-value]
pipeline = (
Pipeline.start()
.pipe(str.strip)
.pipe(str.upper)
.pipe(lambda s: s + "!")
.pipe(len)
)
text = " hello world "
result = pipeline.run(text)
print(f"Pipeline: {text.strip()!r} -> {text.strip().upper()!r} -> {text.strip().upper() + '!'!r} -> {result}")
print(f"Result: {result}")Expected Output
Pipeline: 'hello world' -> 'HELLO WORLD' -> 'HELLO WORLD!' -> 13
Result: 13Hints
Hint 1: Use two TypeVars A and B. Pipeline[A, B] represents a transformation from A to B. Composing Pipeline[A, B] with Pipeline[B, C] gives Pipeline[A, C].
Hint 2: Store the steps as a list of callables. pipe() appends a step and returns a new Pipeline with updated output type.
Create a Sink[T_contra] contravariant consumer. Demonstrate that a Sink[Animal] can be used where a Sink[Dog] is expected (contravariance).
from typing import TypeVar, Generic, Callable
T_contra = TypeVar("T_contra", contravariant=True)
class Sink(Generic[T_contra]):
def __init__(self, handler: Callable[[T_contra], None]) -> None:
self._handler = handler
def accept(self, value: T_contra) -> None:
self._handler(value)
class Animal:
def __init__(self, name: str) -> None:
self.name = name
def speak(self) -> str:
return f"{self.name} makes a sound"
class Dog(Animal):
def speak(self) -> str:
return f"{self.name} barks"
def handle_animal(a: Animal) -> None:
print(f" Handling: {a.speak()}")
# A Sink[Animal] can handle any Animal — including Dog
animal_sink: Sink[Animal] = Sink(handle_animal)
dog: Dog = Dog("Rex")
animal: Animal = Animal("Generic")
print(f"AnimalSink accepts Animal: True")
animal_sink.accept(animal)
print(f"AnimalSink accepts Dog (subtype): True")
animal_sink.accept(dog)
# Contravariance: Sink[Animal] is assignable to Sink[Dog]
dog_sink: Sink[Dog] = animal_sink # valid due to contravariance
print(f"Contravariant: Sink[Animal] usable as Sink[Dog]: {isinstance(dog_sink, Sink)}")Expected Output
AnimalSink accepts Animal: True
AnimalSink accepts Dog (subtype): True
Contravariant: Sink[Animal] usable as Sink[Dog]: TrueHints
Hint 1: Declare T_contra = TypeVar("T_contra", contravariant=True). A Sink[Animal] is a supertype of Sink[Dog] — it is safe to use a more general consumer where a specific one is expected.
Hint 2: Implement accept(value: T_contra) -> None. A consumer that handles Animal can certainly handle Dog.
Implement a Maybe[T] monad with Just and Nothing variants. Support map, flatMap, and method chaining that short-circuits on Nothing.
from typing import TypeVar, Generic, Callable, Optional
T = TypeVar("T")
U = TypeVar("U")
class Maybe(Generic[T]):
def map(self, f: Callable[[T], U]) -> "Maybe[U]":
raise NotImplementedError
def flat_map(self, f: Callable[[T], "Maybe[U]"]) -> "Maybe[U]":
raise NotImplementedError
def get_or(self, default: T) -> T:
raise NotImplementedError
class Just(Maybe[T]):
def __init__(self, value: T) -> None:
self._value = value
def map(self, f: Callable[[T], U]) -> Maybe[U]:
return Just(f(self._value))
def flat_map(self, f: Callable[[T], Maybe[U]]) -> Maybe[U]:
return f(self._value)
def get_or(self, default: T) -> T:
return self._value
def __repr__(self) -> str:
return f"Just({self._value!r})"
class _Nothing(Maybe):
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def map(self, f):
return self
def flat_map(self, f):
return self
def get_or(self, default):
return default
def __repr__(self) -> str:
return "Nothing"
Nothing = _Nothing()
def safe_sqrt(x: float) -> Maybe[float]:
import math
if x < 0:
return Nothing
return Just(math.sqrt(x))
def safe_halve(x: float) -> Maybe[float]:
if x == 0:
return Nothing
return Just(x / 2)
j: Maybe[int] = Just(42)
print(j)
print(Nothing)
print(f"flatMap Just: {j.flat_map(lambda x: Just(x * 2))}")
print(f"flatMap Nothing: {Nothing.flat_map(lambda x: Just(x * 2))}")
chained = Just(42.0).flat_map(safe_sqrt).flat_map(safe_halve)
print(f"chain: {chained}")
broken = Just(-1.0).flat_map(safe_sqrt).flat_map(safe_halve)
print(f"chain with None-returning step: {broken}")Expected Output
Just(42)
Nothing
flatMap Just: Just(84)
flatMap Nothing: Nothing
chain: Just(21)
chain with None-returning step: NothingHints
Hint 1: Create Just[T] and Nothing as subclasses of Maybe[T]. Nothing is a singleton.
Hint 2: flatMap (or bind) on Just calls f(value) and returns the result. On Nothing it returns Nothing. map wraps the result in Just automatically.
