Skip to main content

Python Type System Practice Problems & Exercises

Practice: Type System and Dynamic Typing

10 problems3 Easy4 Medium3 Hard35–50 min
← Back to lesson

Easy

#1Name Rebinding ShowcaseEasy
dynamic-typingrebindingtype

Predict the output. A single name is rebound to four different types. What does type() return at each step?

Python
x = 42
print(type(x))

x = "hello"
print(type(x))

x = [1, 2, 3]
print(type(x))

x = {"key": "value"}
print(type(x))
Solution
<class 'int'>
<class 'str'>
<class 'list'>
<class 'dict'>

Python is dynamically typed — names have no fixed type. Each assignment rebinds the name x to a completely new object. The old object is not modified; if nothing else references it, it becomes eligible for garbage collection.

Key insight: Types belong to objects, not to names. The name x is just a pointer in the local namespace dictionary.

Expected Output
<class 'int'>\n<class 'str'>\n<class 'list'>\n<class 'dict'>
Hints

Hint 1: In Python, variables are just names — labels stuck on objects. There is no type declaration locking a name to one type.

Hint 2: Reassigning a name does not modify the old object. It just moves the label to a new object.

#2isinstance vs type() ShowdownEasy
isinstancetypeinheritance

Write a function check_types(value) that prints whether value passes an isinstance(value, int) check and whether type(value) is int is True.

check_types(42) # isinstance: True, type() match: True
check_types(True) # isinstance: True, type() match: ???
check_types(False) # isinstance: True, type() match: ???
Solution
def check_types(value):
is_inst = isinstance(value, int)
is_exact = type(value) is int
print(f"isinstance: {is_inst}, type() match: {is_exact}")

check_types(42)
check_types(True)
check_types(False)

Why it works: bool is a subclass of int (class bool(int)). So isinstance(True, int) returns True because inheritance is respected. But type(True) returns <class 'bool'>, not <class 'int'>, so the exact type() is int check fails.

Rule of thumb: Use isinstance() for production code — it handles inheritance correctly. Use type() is only when you need an exact type match and want to exclude subclasses.

def check_types(value):
    """Print results of isinstance and type() checks against int."""
    pass
Expected Output
isinstance: True, type() match: True\nisinstance: True, type() match: False\nisinstance: True, type() match: False
Hints

Hint 1: `isinstance()` respects inheritance — it returns True for subclasses. `type()` returns the exact runtime type.

Hint 2: `bool` is a subclass of `int` in Python. So `isinstance(True, int)` is True, but `type(True) is int` is False.

#3Duck Typing PredictionEasy
duck-typinglenpolymorphism

Predict the output. The function below never checks what type it receives. What happens when you pass different types?

Python
def report_length(thing):
    print(len(thing))

report_length("hello")
report_length([10, 20, 30])
report_length({"a": 1, "b": 2})
report_length((7, 8, 9))
Solution
5
3
2
3

report_length works with any object that supports len() — meaning any object whose class defines __len__. This is duck typing in action: the function never asks "are you a list?" or "are you a string?". It just calls len() and trusts the object to respond.

Key insight: Duck typing makes Python code naturally polymorphic. One function works with strings, lists, dicts, tuples, sets, and any custom class that implements __len__ — without a single type check.

Expected Output
5\n3\n2\n3
Hints

Hint 1: Duck typing: "If it quacks like a duck, it is a duck." The function only cares about behavior, not type.

Hint 2: `len()` works on any object that implements `__len__`. Strings, lists, dicts, tuples, and sets all do.


Medium

#4Universal FlattenerMedium
duck-typingiterablesrecursion

Write a function flatten(iterable) that recursively flattens any nested combination of lists, tuples, sets, and generators into a flat list. Strings should be treated as atomic values, not recursed into.

print(flatten([1, [2, 3], [4, [5, 6]]]))
print(flatten([1, "hello", (2, 3), [True]]))
print(flatten((1, 2, [3, (4, 5)])))
Solution
def flatten(iterable):
result = []
for item in iterable:
if hasattr(item, '__iter__') and not isinstance(item, (str, bytes)):
result.extend(flatten(item))
else:
result.append(item)
return result

print(flatten([1, [2, 3], [4, [5, 6]]]))
print(flatten([1, "hello", (2, 3), [True]]))
print(flatten((1, 2, [3, (4, 5)])))

Why it works: The function uses duck typing — it checks for the __iter__ attribute rather than testing against a fixed list of types. This means it automatically works with lists, tuples, sets, generators, and any custom iterable.

Strings are special-cased because iterating over a string yields single characters, which are themselves strings — causing infinite recursion without the isinstance(item, (str, bytes)) guard.

Design pattern: This is the canonical duck-typing approach: check for capability (__iter__), not identity (type() is list).

def flatten(iterable):
    """Flatten any nested iterable into a single list.
    Strings should NOT be recursed into.
    """
    pass
Expected Output
[1, 2, 3, 4, 5, 6]\n[1, 'hello', 2, 3, True]\n[1, 2, 3, 4, 5]
Hints

Hint 1: Use duck typing: try to iterate over each element. If it works and it is not a string, recurse into it.

Hint 2: Strings are iterable (each character is a string), so you must special-case them to avoid infinite recursion.

Hint 3: Use `hasattr(item, "__iter__")` or try/except around `iter()` to check if something is iterable.

#5Runtime Type Checker DecoratorMedium
decoratorstype-hintsannotationsruntime-checks

Build a decorator enforce_types that reads a function's type annotations and raises TypeError at runtime if any annotated argument receives a value of the wrong type.

@enforce_types
def multiply(a: int, b: int) -> int:
return a * b

@enforce_types
def greet(a: str, b: str) -> str:
return a + " " + b

print(multiply(3, 5))

try:
multiply(3, "five")
except TypeError as e:
print(f"TypeError caught: {e}")

print(greet("hello", "world"))

try:
greet(42, "world")
except TypeError as e:
print(f"TypeError caught: {e}")
Solution
import functools
import inspect

def enforce_types(func):
sig = inspect.signature(func)
annotations = func.__annotations__

@functools.wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for param_name, value in bound.arguments.items():
if param_name in annotations and param_name != 'return':
expected = annotations[param_name]
if not isinstance(value, expected):
raise TypeError(
f"Expected {expected} for '{param_name}', "
f"got {type(value)}"
)
return func(*args, **kwargs)
return wrapper

@enforce_types
def multiply(a: int, b: int) -> int:
return a * b

@enforce_types
def greet(a: str, b: str) -> str:
return a + " " + b

print(multiply(3, 5))

try:
multiply(3, "five")
except TypeError as e:
print(f"TypeError caught: {e}")

print(greet("hello", "world"))

try:
greet(42, "world")
except TypeError as e:
print(f"TypeError caught: {e}")

Why it works: inspect.signature(func).bind(*args, **kwargs) maps positional and keyword arguments to their parameter names. We then compare each argument's actual type against the annotation using isinstance().

Key insight: Python's type annotations are just metadata at runtime — they have zero enforcement by default. This decorator bridges the gap between static hints and runtime safety, which is exactly what libraries like pydantic and beartype do at a much larger scale.

def enforce_types(func):
    """Decorator that checks argument types at runtime
    using the function's type annotations.
    Raise TypeError if any annotated argument has wrong type.
    """
    pass
Expected Output
15\nTypeError caught: Expected <class 'int'> for 'b', got <class 'str'>\nhello world\nTypeError caught: Expected <class 'str'> for 'a', got <class 'int'>
Hints

Hint 1: Access annotations via `func.__annotations__` — it is a dict mapping parameter names to types.

Hint 2: Use `inspect.signature` or just zip the function parameters with the passed args.

Hint 3: Use `isinstance()` for the type check, not `type() is`.

#6EAFP vs LBYLMedium
EAFPLBYLtry-exceptduck-typing

Implement two functions that both safely retrieve a value from a dictionary by key. safe_get_lbyl uses the Look Before You Leap pattern (check types and existence first). safe_get_eafp uses the Easier to Ask Forgiveness than Permission pattern (try/except).

Both should return None when the access fails for any reason.

d = {"a": 1, "b": 2}

for key, data in [("a", d), ("z", d), ("a", "not_a_dict"), ("a", None)]:
lbyl = safe_get_lbyl(data, key)
eafp = safe_get_eafp(data, key)
print(f"LBYL: {lbyl} | EAFP: {eafp}")
Solution
def safe_get_lbyl(data, key):
if isinstance(data, dict) and key in data:
return data[key]
return None

def safe_get_eafp(data, key):
try:
return data[key]
except (KeyError, TypeError, IndexError):
return None

d = {"a": 1, "b": 2}

for key, data in [("a", d), ("z", d), ("a", "not_a_dict"), ("a", None)]:
lbyl = safe_get_lbyl(data, key)
eafp = safe_get_eafp(data, key)
print(f"LBYL: {lbyl} | EAFP: {eafp}")

Why both work: LBYL explicitly checks isinstance(data, dict) and key in data before accessing. EAFP just tries the access and catches whatever goes wrong.

Why EAFP is Pythonic:

  • It handles more edge cases — what if data is a defaultdict? A custom Mapping? LBYL's isinstance(data, dict) rejects those. EAFP accepts any subscriptable object.
  • It avoids race conditions — in concurrent code, the key could be deleted between the in check and the access.
  • It is often faster in the happy path — no redundant lookups.

Rule of thumb: EAFP with duck typing is the default Python style. LBYL is appropriate when the "try" has expensive side effects you cannot undo.

def safe_get_lbyl(data, key):
    """LBYL: Look Before You Leap.
    Check type and key existence before accessing.
    Return None if anything is wrong.
    """
    pass

def safe_get_eafp(data, key):
    """EAFP: Easier to Ask Forgiveness than Permission.
    Just try the access, catch exceptions.
    Return None if anything goes wrong.
    """
    pass
Expected Output
LBYL: 1 | EAFP: 1\nLBYL: None | EAFP: None\nLBYL: None | EAFP: None\nLBYL: None | EAFP: None
Hints

Hint 1: LBYL checks conditions first: `if isinstance(data, dict) and key in data:`

Hint 2: EAFP wraps the operation in try/except and catches the likely exceptions.

Hint 3: EAFP is generally preferred in Python — it handles edge cases you might not anticipate in LBYL checks.

#7Polymorphism Without InheritanceMedium
duck-typingpolymorphismprotocol

Four classes all have a speak() method but share no common base class. Write roll_call(entities) that calls speak() on each entity and prints the result — using pure duck typing with zero type checks.

Then create a mixed list and call roll_call to prove it works.

# Part 1: basic roll call
roll_call([Dog(), Cat(), Duck(), Robot()])

print("---")

# Part 2: mixed list with numbering
animals = [Dog(), Duck(), Robot()]
for i, a in enumerate(animals, 1):
print(f"Speaker {i}: {a.speak()}")
Solution
class Dog:
def speak(self):
return "Woof!"

class Cat:
def speak(self):
return "Meow!"

class Duck:
def speak(self):
return "Quack!"

class Robot:
def speak(self):
return "Beep boop!"

def roll_call(entities):
for entity in entities:
print(entity.speak())

roll_call([Dog(), Cat(), Duck(), Robot()])

print("---")

animals = [Dog(), Duck(), Robot()]
for i, a in enumerate(animals, 1):
print(f"Speaker {i}: {a.speak()}")

Why it works: Python does not need an interface Speaker or abstract class Animal to achieve polymorphism. Any object with a speak() method works. This is fundamentally different from Java or C++ where polymorphism requires inheritance or interface implementation.

Key insight: In statically typed languages, you need a shared type to put objects in the same list. In Python, lists are heterogeneous by default — you can mix Dog, Cat, Duck, and Robot freely because the type system does not constrain the container.

class Dog:
    def speak(self):
        return "Woof!"

class Cat:
    def speak(self):
        return "Meow!"

class Duck:
    def speak(self):
        return "Quack!"

class Robot:
    def speak(self):
        return "Beep boop!"

def roll_call(entities):
    """Call speak() on each entity and print the result.
    No type checks — pure duck typing.
    """
    pass
Expected Output
Woof!\nMeow!\nQuack!\nBeep boop!\n---\nSpeaker 1: Woof!\nSpeaker 2: Quack!\nSpeaker 3: Beep boop!
Hints

Hint 1: Just iterate and call `.speak()` on each object. No isinstance, no base class needed.

Hint 2: Duck typing means any object with a `.speak()` method is valid — no shared parent required.


Hard

#8Type-Enforced ContainerHard
genericsisinstanceruntime-validationOOP

Build a TypedList class that behaves like a regular list but enforces that every element is an instance of a specified type. Raise TypeError with a clear message on any violation.

# Integer-only list
nums = TypedList(int, [1, 2, 3])
print(nums)

nums.extend([4, 5, 6])
print(nums)

nums[0] = 99
print(nums)

try:
nums.append("hello")
except TypeError as e:
print(f"Caught: {e}")

try:
nums[0] = "bad"
except TypeError as e:
print(f"Caught: {e}")

# String-only list
words = TypedList(str, ["hello", "world"])
print(words)
print(len(words) + len(nums))
Solution
class TypedList:
def __init__(self, allowed_type, initial=None):
self._type = allowed_type
self._data = []
if initial is not None:
for item in initial:
self._check(item)
self._data = list(initial)

def _check(self, item):
if not isinstance(item, self._type):
raise TypeError(
f"TypedList({self._type.__name__}) only accepts "
f"{self._type}, got {type(item)}: {item!r}"
)

def append(self, item):
self._check(item)
self._data.append(item)

def extend(self, items):
items = list(items)
for item in items:
self._check(item)
self._data.extend(items)

def __setitem__(self, index, item):
self._check(item)
self._data[index] = item

def __getitem__(self, index):
return self._data[index]

def __len__(self):
return len(self._data)

def __repr__(self):
return f"TypedList({self._type.__name__}, {self._data!r})"

nums = TypedList(int, [1, 2, 3])
print(nums)

nums.extend([4, 5, 6])
print(nums)

nums[0] = 99
print(nums)

try:
nums.append("hello")
except TypeError as e:
print(f"Caught: {e}")

try:
nums[0] = "bad"
except TypeError as e:
print(f"Caught: {e}")

words = TypedList(str, ["hello", "world"])
print(words)
print(len(words) + len(nums))

Why it works: TypedList uses composition (wrapping an internal list) rather than inheritance. Every mutation point — __init__, append, extend, __setitem__ — calls _check() which uses isinstance() to validate.

Design decisions:

  • extend converts to a list first and validates everything before inserting. This prevents partial inserts where some items get added before a bad one raises an error.
  • Using isinstance() means True/False are accepted in an int TypedList (since bool subclasses int). This is intentional — it matches Python's own type hierarchy.
  • This is essentially what libraries like pydantic do for data validation, but at the container level.
class TypedList:
    """A list that only accepts elements of a specified type.
    Validate on insert, append, extend, and __setitem__.
    """
    def __init__(self, allowed_type, initial=None):
        pass

    def append(self, item):
        pass

    def extend(self, items):
        pass

    def __setitem__(self, index, item):
        pass

    def __getitem__(self, index):
        pass

    def __len__(self):
        pass

    def __repr__(self):
        pass
Expected Output
TypedList(int, [1, 2, 3])\nTypedList(int, [1, 2, 3, 4, 5, 6])\nTypedList(int, [99, 2, 3, 4, 5, 6])\nCaught: TypedList(int) only accepts <class 'int'>, got <class 'str'>: 'hello'\nCaught: TypedList(int) only accepts <class 'int'>, got <class 'str'>: 'bad'\nTypedList(str, ['hello', 'world'])\n3
Hints

Hint 1: Store the allowed type and validate in a private `_check` method that raises TypeError.

Hint 2: For `extend`, validate ALL items before adding any — do not partially insert.

Hint 3: Remember to validate in `__init__` if initial data is provided.

#9Mini Type Inference EngineHard
type-inferencedynamic-typingintrospection

Build a type inference engine that examines a list of values and returns the most specific common type description.

print(infer_type([1, 2, 3, 4]))
print(infer_type(["a", "b", "c"]))
print(infer_type([1, 2.5, 3]))
print(infer_type([True, 1, 2.5]))
print(infer_type([[1, 2], (3, 4), {5}]))
print(infer_type([1, "hello", 3.14]))
print(infer_type([]))
Solution
def infer_type(values):
if not values:
return "empty"

types = set(type(v) for v in values)

if len(types) == 1:
return types.pop()

numeric_types = {int, float, bool}
if types.issubset(numeric_types):
return "numeric"

non_str_all_iterable = all(
hasattr(v, '__iter__') and not isinstance(v, str)
for v in values
)
if non_str_all_iterable:
return "iterable"

return "mixed"

print(infer_type([1, 2, 3, 4]))
print(infer_type(["a", "b", "c"]))
print(infer_type([1, 2.5, 3]))
print(infer_type([True, 1, 2.5]))
print(infer_type([[1, 2], (3, 4), {5}]))
print(infer_type([1, "hello", 3.14]))
print(infer_type([]))

Why it works: The function builds a set of exact types, then applies progressively looser rules:

  1. Exact match — if only one type exists in the set, return it directly.
  2. Numeric wideningbool, int, and float are all numeric. bool is included because bool subclasses int, and in numeric contexts True acts as 1.
  3. Iterable grouping — if everything is iterable (lists, tuples, sets, etc.) but not strings, group as "iterable". Strings are excluded because they are iterable at the character level, which is rarely what you mean.
  4. Fallback — anything that does not fit a clean category is "mixed".

Key insight: This is a simplified version of what pandas does when inferring column dtypes, or what mypy does when computing union types. Real type inference is much harder (handling None, generics, callable signatures), but the core idea is the same: find the narrowest type that encompasses all observed values.

def infer_type(values):
    """Given a list of values, infer the most specific common type.
    
    Rules:
    - If all values are the same type, return that type.
    - If all values are numeric (int/float/bool), return 'numeric'.
    - If all values are iterable (but not str), return 'iterable'.
    - If there is a mix of str and non-str, return 'mixed'.
    - Empty list returns 'empty'.
    """
    pass
Expected Output
<class 'int'>\n<class 'str'>\nnumeric\nnumeric\niterable\nmixed\nempty
Hints

Hint 1: Use `type()` to get exact types and `set()` to find unique types.

Hint 2: Remember that `bool` is a subclass of `int`. Decide whether to treat bools as their own type or as numeric.

Hint 3: For the iterable check, use `hasattr(v, "__iter__")` but exclude strings.

#10Duck Type Protocol CheckerHard
duck-typinghasattrprotocolsintrospection

Build a protocol checker that verifies whether an object satisfies a duck-type protocol (a set of required methods/attributes). This is what typing.Protocol does at type-check time — but you are doing it at runtime.

class MyStream:
def __iter__(self):
return self
def __next__(self):
raise StopIteration
def __len__(self):
return 0
def __getitem__(self, key):
raise KeyError(key)

# Test against different protocols
objects = [
("list", [1, 2, 3]),
("dict", {"a": 1}),
("int", 42),
("generator", (x for x in range(3))),
("MyStream", MyStream()),
]

protocols = [
("ITERABLE", ITERABLE_PROTOCOL),
("SIZED", SIZED_PROTOCOL),
("MAPPING", MAPPING_PROTOCOL),
]

test_cases = [
("list", "ITERABLE"),
("list", "SIZED"),
("list", "MAPPING"),
("dict", "MAPPING"),
("int", "ITERABLE"),
("generator", "SIZED"),
("MyStream", "ITERABLE"),
("MyStream", "SIZED"),
("MyStream", "MAPPING"),
]

obj_map = dict(objects)
proto_map = dict(protocols)

for obj_name, proto_name in test_cases:
ok, missing = check_protocol(obj_map[obj_name], proto_map[proto_name])
print(f"{obj_name} vs {proto_name}: {ok}, missing: {missing}")
Solution
def check_protocol(obj, protocol):
missing = []
for name, check in protocol.items():
if not hasattr(obj, name):
missing.append(name)
elif check is None and not callable(getattr(obj, name)):
missing.append(name)
return (len(missing) == 0, missing)

ITERABLE_PROTOCOL = {
'__iter__': None,
}

SIZED_PROTOCOL = {
'__len__': None,
}

MAPPING_PROTOCOL = {
'__getitem__': None,
'__contains__': None,
'__iter__': None,
'__len__': None,
'keys': None,
'values': None,
'items': None,
}

class MyStream:
def __iter__(self):
return self
def __next__(self):
raise StopIteration
def __len__(self):
return 0
def __getitem__(self, key):
raise KeyError(key)

objects = [
("list", [1, 2, 3]),
("dict", {"a": 1}),
("int", 42),
("generator", (x for x in range(3))),
("MyStream", MyStream()),
]

protocols = [
("ITERABLE", ITERABLE_PROTOCOL),
("SIZED", SIZED_PROTOCOL),
("MAPPING", MAPPING_PROTOCOL),
]

test_cases = [
("list", "ITERABLE"),
("list", "SIZED"),
("list", "MAPPING"),
("dict", "MAPPING"),
("int", "ITERABLE"),
("generator", "SIZED"),
("MyStream", "ITERABLE"),
("MyStream", "SIZED"),
("MyStream", "MAPPING"),
]

obj_map = dict(objects)
proto_map = dict(protocols)

for obj_name, proto_name in test_cases:
ok, missing = check_protocol(obj_map[obj_name], proto_map[proto_name])
print(f"{obj_name} vs {proto_name}: {ok}, missing: {missing}")

Why it works: The checker iterates over every method/attribute required by the protocol and uses hasattr() to verify existence and callable() to verify it is a method (not just a data attribute). Missing entries are collected into a list.

Key insight: This is a runtime implementation of Python 3.8's typing.Protocol. The difference is that typing.Protocol operates at static analysis time (mypy, pyright) while this operates at runtime. Both use the same underlying idea: structural subtyping — an object conforms to a protocol if it has the right attributes, regardless of its class hierarchy.

Why this matters:

  • list satisfies ITERABLE and SIZED but not MAPPING (no keys, values, items).
  • dict satisfies all three because it implements the full mapping interface.
  • A generator satisfies ITERABLE but not SIZED — you cannot call len() on a generator.
  • MyStream is a custom class that implements some but not all mapping methods, demonstrating how duck typing is granular — you either have the method or you do not.
def check_protocol(obj, protocol):
    """Check if obj satisfies a protocol.
    
    A protocol is a dict mapping method/attribute names
    to their expected signatures (or None for any callable).
    
    Returns (bool, list_of_missing) — True if satisfied,
    plus a list of what is missing.
    """
    pass

# Protocols defined as {name: expected_check}
# None means 'must be callable', 'attr' means 'must exist'
ITERABLE_PROTOCOL = {
    '__iter__': None,
}

SIZED_PROTOCOL = {
    '__len__': None,
}

MAPPING_PROTOCOL = {
    '__getitem__': None,
    '__contains__': None,
    '__iter__': None,
    '__len__': None,
    'keys': None,
    'values': None,
    'items': None,
}
Expected Output
list vs ITERABLE: True, missing: []\nlist vs SIZED: True, missing: []\nlist vs MAPPING: False, missing: ['keys', 'values', 'items']\ndict vs MAPPING: True, missing: []\nint vs ITERABLE: False, missing: ['__iter__']\ngenerator vs SIZED: False, missing: ['__len__']\nMyStream vs ITERABLE: True, missing: []\nMyStream vs SIZED: True, missing: []\nMyStream vs MAPPING: False, missing: ['__contains__', 'keys', 'values', 'items']
Hints

Hint 1: Use `hasattr(obj, name)` to check if the attribute exists.

Hint 2: Use `callable(getattr(obj, name))` to verify it is callable when the protocol requires it.

Hint 3: Collect all missing attributes into a list and return whether the list is empty.

© 2026 EngineersOfAI. All rights reserved.