Python Runtime Type Checking Practice Problems & Exercises
Practice: Runtime Type Checking
← Back to lessonEasy
Write a safe_describe(value) function that checks the runtime type with isinstance and returns a formatted description, with a fallback for unknown types.
from typing import Any
def safe_describe(value: Any) -> str:
if isinstance(value, bool):
return f"Unknown type for {value}"
elif isinstance(value, int):
return f"{value} -> int"
elif isinstance(value, float):
return f"{value} -> float"
elif isinstance(value, str):
return f"{value} -> str"
elif isinstance(value, list):
return f"{value} -> list"
else:
return f"Unknown type for {value}"
values = [42, 3.14, "hello", [1, 2], True]
for v in values:
print(safe_describe(v))Expected Output
42 -> int
3.14 -> float
hello -> str
[1, 2] -> list
Unknown type for TrueHints
Hint 1: isinstance(value, (int, float, str, list)) checks against multiple types at once.
Hint 2: Check bool before int since bool is a subclass of int.
Inspect a class's type annotations at runtime using __annotations__ and typing.get_type_hints(). Check which fields are typed as str.
from typing import get_type_hints
class User:
name: str
age: int
email: str
def __init__(self, name: str, age: int, email: str) -> None:
self.name = name
self.age = age
self.email = email
raw = User.__annotations__
resolved = get_type_hints(User)
# Print a simplified version without module path
simple = {k: v.__name__ for k, v in resolved.items()}
print(f"User annotations: {simple}")
print(f"Fields: {list(resolved.keys())}")
str_fields = [k for k, v in resolved.items() if v is str]
print(f"All strings? {len(str_fields) == len(resolved)}")Expected Output
User annotations: {'name': str, 'age': int, 'email': str}
Fields: ['name', 'age', 'email']
All strings? FalseHints
Hint 1: Class.__annotations__ returns the raw annotations dict. typing.get_type_hints() resolves forward references and includes inherited annotations.
Hint 2: The two may differ when forward references (strings) are used as annotation values.
Write a decorator type_checked that reads a function's annotations at runtime and raises TypeError if any argument has the wrong type.
import functools
import inspect
from typing import get_type_hints
def type_checked(func):
hints = get_type_hints(func)
sig = inspect.signature(func)
@functools.wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for name, value in bound.arguments.items():
if name in hints and name != "return":
expected = hints[name]
if hasattr(expected, "__origin__"):
continue # skip complex generics
if not isinstance(value, expected):
raise TypeError(
f"{name} expected {expected.__name__}, "
f"got {type(value).__name__}"
)
return func(*args, **kwargs)
return wrapper
@type_checked
def greet(name: str, age: int) -> str:
return f"Hello {name}, age {age}"
try:
result = greet("Alice", 30)
print(f"greet('Alice', 30) OK")
except TypeError as e:
print(f"TypeError: {e}")
try:
greet("Alice", "thirty")
except TypeError as e:
print(f"TypeError: {e}")Expected Output
greet('Alice', 30) OK
TypeError: age expected int, got strHints
Hint 1: Use get_type_hints(func) to get the parameter annotations. Pair them with the actual arguments via inspect.signature.
Hint 2: Compare isinstance(value, expected) for each annotated argument.
Medium
Build a ValidatedDataclass mixin that validates all field types in __post_init__ using get_type_hints. Apply it to a User dataclass.
from dataclasses import dataclass
from typing import get_type_hints
class ValidatedDataclass:
def __post_init__(self) -> None:
hints = get_type_hints(type(self))
for field_name, expected_type in hints.items():
value = getattr(self, field_name)
# Skip complex generics like Optional, Union, List, etc.
if hasattr(expected_type, "__origin__"):
continue
if not isinstance(value, expected_type):
raise TypeError(
f"{field_name}: expected {expected_type.__name__}, "
f"got {type(value).__name__}"
)
@dataclass
class User(ValidatedDataclass):
name: str
age: int
email: str
u = User(name="Alice", age=30, email="[email protected]")
print(u)
try:
User(name="Bob", age="thirty", email="[email protected]")
except TypeError as e:
print(f"TypeError: {e}")Expected Output
User(name='Alice', age=30, email='[email protected]')
TypeError: age: expected int, got strHints
Hint 1: In __post_init__, call get_type_hints(type(self)) and for each field, check isinstance(getattr(self, field), expected_type).
Hint 2: Skip Optional fields or Union types that are harder to check — or handle them with get_args.
Write a validate_typed_dict(data, schema) function that returns a list of all type and key errors found in a dict against a TypedDict schema.
from typing import Dict, Any, List, get_type_hints
try:
from typing import TypedDict
except ImportError:
from typing_extensions import TypedDict
class PersonRecord(TypedDict):
name: str
age: int
active: bool
def validate_typed_dict(data: Dict[str, Any], schema) -> List[str]:
errors: List[str] = []
hints = get_type_hints(schema)
for key, expected_type in hints.items():
if key not in data:
errors.append(f"Missing key: {key}")
continue
value = data[key]
# Skip complex generics
if hasattr(expected_type, "__origin__"):
continue
if not isinstance(value, expected_type):
errors.append(
f"Wrong type for {key}: expected {expected_type.__name__}, "
f"got {type(value).__name__}"
)
return errors
valid_data: Dict[str, Any] = {"name": "Alice", "age": 30, "active": True}
missing_age: Dict[str, Any] = {"name": "Alice", "active": True}
bad_name: Dict[str, Any] = {"name": 42, "age": 30, "active": True}
print(f"Valid: {validate_typed_dict(valid_data, PersonRecord) == []}")
print(f"Errors for missing age: {validate_typed_dict(missing_age, PersonRecord)}")
print(f"Errors for bad name type: {validate_typed_dict(bad_name, PersonRecord)}")Expected Output
Valid: True
Errors for missing age: ['Missing key: age']
Errors for bad name type: ["Wrong type for name: expected str, got int"]Hints
Hint 1: Use get_type_hints(TypedDictClass) to get the expected field types. Iterate over keys, check presence and isinstance.
Hint 2: Collect all errors in a list and return them all at once rather than raising on the first error.
Use typing.get_origin and typing.get_args to inspect generic type aliases at runtime and print their structure.
from typing import List, Dict, Optional, Union, get_origin, get_args
import typing
def describe_type(hint) -> str:
origin = get_origin(hint)
args = get_args(hint)
if origin is None:
name = getattr(hint, "__name__", str(hint))
return f"{name}: not generic"
origin_name = getattr(origin, "__name__", str(origin))
if origin is Union:
origin_name = "Union"
arg_names = tuple(getattr(a, "__name__", str(a)) for a in args)
return f"{hint}: origin={origin_name}, args={arg_names}"
hints = [
List[int],
Dict[str, float],
Optional[str],
int,
]
for h in hints:
print(describe_type(h))Expected Output
List[int]: origin=list, args=(int,)
Dict[str, float]: origin=dict, args=(str, float)
Optional[str]: origin=Union, args=(str, NoneType)
int: not genericHints
Hint 1: typing.get_origin(hint) returns the base generic (list, dict, Union, etc.) or None for non-generic types.
Hint 2: typing.get_args(hint) returns the type arguments as a tuple.
Write check_annotations(cls) that verifies all public methods have return type annotations and all parameters (except self) are annotated.
import inspect
from typing import get_type_hints, List
def check_annotations(cls) -> List[str]:
errors: List[str] = []
for name, method in inspect.getmembers(cls, predicate=inspect.isfunction):
if name.startswith("_"):
continue
try:
hints = get_type_hints(method)
except Exception:
hints = {}
sig = inspect.signature(method)
params = [
p for p in sig.parameters
if p != "self" and p != "cls"
]
if "return" not in hints:
errors.append(f"{name}: missing return annotation")
for param in params:
if param not in hints:
errors.append(f"{name}: missing param annotation for {param}")
return errors
class FullyAnnotated:
def process(self, data: str) -> str:
return data.upper()
def transform(self, value: int) -> int:
return value * 2
class StrictService:
def process(self, data: str): # missing return
return data.upper()
def transform(self, data) -> int: # missing param annotation
return len(data)
print(f"FullyAnnotated: {'OK' if not check_annotations(FullyAnnotated) else 'FAIL'}")
errors = check_annotations(StrictService)
print(f"Missing annotations on StrictService: {errors}")Expected Output
FullyAnnotated: OK
Missing annotations on StrictService: ['process: missing return annotation', 'transform: missing param annotation for data']Hints
Hint 1: Use inspect.getmembers(cls, predicate=inspect.isfunction) to enumerate methods.
Hint 2: get_type_hints(method) returns the annotation dict. Check for "return" key and each parameter name.
Hard
Write a recursive type_check(value, hint) function that validates complex nested types like List[int], Dict[str, float], and Optional[List[str]] at runtime.
from typing import Any, List, Dict, Optional, Union, get_origin, get_args
def type_check(value: Any, hint: Any) -> tuple:
"""Returns (is_valid: bool, error_msg: str)"""
origin = get_origin(hint)
args = get_args(hint)
if origin is None:
if hint is type(None):
ok = value is None
return ok, ("" if ok else f"{value!r} is not None")
if not isinstance(value, hint):
name = getattr(hint, "__name__", str(hint))
return False, f"{value!r} is not {name}"
return True, ""
if origin is Union:
for arg in args:
ok, _ = type_check(value, arg)
if ok:
return True, ""
return False, f"{value!r} does not match {hint}"
if origin is list:
if not isinstance(value, list):
return False, f"{value!r} is not a list"
item_type = args[0] if args else Any
if item_type is not Any:
for item in value:
ok, msg = type_check(item, item_type)
if not ok:
name = getattr(item_type, "__name__", str(item_type))
return False, f"element {item!r} is not {name}"
return True, ""
if origin is dict:
if not isinstance(value, dict):
return False, f"{value!r} is not a dict"
k_type, v_type = (args[0], args[1]) if len(args) == 2 else (Any, Any)
for k, v in value.items():
if k_type is not Any:
ok, _ = type_check(k, k_type)
if not ok:
kname = getattr(k_type, "__name__", str(k_type))
return False, f"key {k!r} is not {kname}"
if v_type is not Any:
ok, _ = type_check(v, v_type)
if not ok:
vname = getattr(v_type, "__name__", str(v_type))
return False, f"value {v!r} is not {vname}"
return True, ""
return True, ""
cases = [
([1, 2, 3], List[int], "[1,2,3] valid as List[int]"),
([1, "a", 3], List[int], "[1,'a',3] valid as List[int]"),
({"a": 1}, Dict[str, int], "{'a':1} valid as Dict[str,int]"),
({"a": "x"}, Dict[str, int], "{'a':'x'} valid as Dict[str,int]"),
]
for value, hint, label in cases:
ok, err = type_check(value, hint)
if ok:
print(f"{label}: True")
else:
print(f"{label}: False — {err}")Expected Output
[1,2,3] valid as List[int]: True
[1,'a',3] valid as List[int]: False — element 'a' is not int
{'a':1} valid as Dict[str,int]: True
{'a':'x'} valid as Dict[str,int]: False — value 'x' is not intHints
Hint 1: Use get_origin to detect list, dict, Union, Optional and recurse into their args.
Hint 2: For Union/Optional, check if the value matches any of the args types.
Build a BaseModel with a metaclass that auto-generates a validated __init__ from annotations. It should raise ValidationError with field name, expected type, and actual value.
from typing import get_type_hints
class ValidationError(Exception):
pass
class ModelMeta(type):
def __new__(mcs, name, bases, namespace):
annotations = namespace.get("__annotations__", {})
field_names = list(annotations.keys())
def generated_init(self, **kwargs):
hints = get_type_hints(type(self))
for fname in field_names:
if fname not in kwargs:
raise ValidationError(f"{fname}: missing required field")
value = kwargs[fname]
expected = hints.get(fname)
if expected and not hasattr(expected, "__origin__"):
if not isinstance(value, expected):
raise ValidationError(
f"{fname}: expected {expected.__name__}, "
f"got {type(value).__name__} (value: {value!r})"
)
setattr(self, fname, value)
def generated_repr(self):
parts = [f"{f}={getattr(self, f)!r}" for f in field_names]
return f"{name}(" + ", ".join(parts) + ")"
namespace["__init__"] = generated_init
namespace["__repr__"] = generated_repr
return super().__new__(mcs, name, bases, namespace)
class BaseModel(metaclass=ModelMeta):
pass
class User(BaseModel):
name: str
age: int
u = User(name="Alice", age=30)
print(u)
try:
User(name="Bob", age="thirty")
except ValidationError as e:
print(f"ValidationError: {e}")
try:
User(name=99, age=30)
except ValidationError as e:
print(f"ValidationError: {e}")Expected Output
User(name='Alice', age=30)
ValidationError: age: expected int, got str (value: 'thirty')
ValidationError: name: expected str, got int (value: 99)Hints
Hint 1: Build a ModelMeta metaclass that reads __annotations__ and generates __init__ with runtime validation.
Hint 2: In the generated __init__, for each field call isinstance(value, expected_type) and raise ValidationError on failure.
Write structurally_satisfies(obj, protocol_cls) that checks at runtime whether an object has all the methods required by a Protocol class, with optional return-type annotation checking.
import inspect
from typing import get_type_hints, List, Any
def structurally_satisfies(obj: Any, protocol_cls: type) -> tuple:
"""Returns (ok: bool, errors: List[str])"""
errors: List[str] = []
for name in dir(protocol_cls):
if name.startswith("_"):
continue
proto_attr = getattr(protocol_cls, name, None)
if not callable(proto_attr):
continue
obj_attr = getattr(obj, name, None)
if obj_attr is None or not callable(obj_attr):
errors.append(f"missing method: {name}")
continue
# Check return type annotation
try:
proto_hints = get_type_hints(proto_attr)
obj_hints = get_type_hints(type(obj).__dict__.get(name, obj_attr))
if "return" in proto_hints and "return" in obj_hints:
if proto_hints["return"] != obj_hints["return"]:
errors.append(f"wrong return type for {name}")
except Exception:
pass
return (len(errors) == 0, errors)
class Quackable:
def quack(self) -> str: ...
def waddle(self) -> None: ...
class Duck:
def quack(self) -> str:
return "Quack!"
def waddle(self) -> None:
pass
class Person:
def quack(self) -> str:
return "I quack like a duck"
def waddle(self) -> None:
pass
class Cat:
def meow(self) -> str:
return "Meow!"
for cls, label in [(Duck(), "Duck"), (Person(), "Person"), (Cat(), "Cat")]:
ok, errs = structurally_satisfies(cls, Quackable)
if ok:
print(f"{label} satisfies Quackable: True")
else:
print(f"{label} satisfies Quackable: False — {errs[0]}")
class BadDuck:
def quack(self) -> int: # wrong return type
return 1
def waddle(self) -> None:
pass
_, errs = structurally_satisfies(BadDuck(), Quackable)
missing_return = any("return" in e for e in errs)
print(f"Missing return type: {missing_return}")Expected Output
Duck satisfies Quackable: True
Person satisfies Quackable: True
Cat satisfies Quackable: False — missing method: quack
Missing return type: TrueHints
Hint 1: inspect.getmembers(obj, callable) lists all callable attributes on the object.
Hint 2: Compare required method names against the object attributes. Optionally compare return type annotations.
Build a SchemaRegistry with a @register_schema(name) decorator. The registry stores TypedDict classes and exposes a validate(name, data) method that returns errors.
from typing import Dict, Any, List, get_type_hints, Type
try:
from typing import TypedDict
except ImportError:
from typing_extensions import TypedDict
class SchemaRegistry:
def __init__(self) -> None:
self._schemas: Dict[str, type] = {}
def register(self, name: str):
def decorator(cls: type) -> type:
self._schemas[name] = cls
return cls
return decorator
def describe(self, name: str) -> Dict[str, str]:
schema = self._schemas[name]
hints = get_type_hints(schema)
return {k: getattr(v, "__name__", str(v)) for k, v in hints.items()}
def validate(self, name: str, data: Dict[str, Any]) -> List[str]:
if name not in self._schemas:
return [f"Unknown schema: {name}"]
schema = self._schemas[name]
hints = get_type_hints(schema)
errors: List[str] = []
for key, expected_type in hints.items():
if key not in data:
errors.append(f"Missing key: {key}")
continue
if hasattr(expected_type, "__origin__"):
continue
value = data[key]
if not isinstance(value, expected_type):
errors.append(
f"{key}: expected {expected_type.__name__}, "
f"got {type(value).__name__}"
)
return errors
registry = SchemaRegistry()
@registry.register("user")
class UserSchema(TypedDict):
name: str
age: int
email: str
print(f"Schema for 'user': {registry.describe('user')}")
valid = {"name": "Alice", "age": 30, "email": "[email protected]"}
invalid = {"name": "Bob", "age": "old"}
v_errors = registry.validate("user", valid)
i_errors = registry.validate("user", invalid)
print(f"Validate user {valid}: {'OK' if not v_errors else 'FAIL — ' + '; '.join(v_errors)}")
print(f"Validate user {invalid}: {'OK' if not i_errors else 'FAIL — ' + '; '.join(i_errors)}")Expected Output
Schema for 'user': {'name': 'str', 'age': 'int', 'email': 'str'}
Validate user {'name': 'Alice', 'age': 30, 'email': '[email protected]'}: OK
Validate user {'name': 'Bob', 'age': 'old'}: FAIL — age: expected str, got intHints
Hint 1: A SchemaRegistry maps schema names to TypedDict classes. validate(name, data) looks up the schema and runs validate_typed_dict.
Hint 2: A @register_schema(name) decorator adds the class to the registry at definition time.
