Runtime Type Checking
Consider this function with perfect type hints:
import json
from typing import TypedDict
class UserPayload(TypedDict):
name: str
age: int
email: str
def create_user(payload: UserPayload) -> str:
return f"Created {payload['name']} ({payload['age']})"
# This passes mypy:
create_user(data)
# But what happens at runtime when data comes from an HTTP request?
raw = json.loads('{"name": "Alice", "age": "thirty", "email": null}')
create_user(raw) # No error! But age is a string, email is None
mypy checks types at development time. Python erases type hints at runtime. json.loads returns Any, and Any is compatible with everything. Your carefully typed UserPayload becomes a lie when data arrives from the outside world.
This lesson bridges the gap between static types and runtime reality.
What You Will Learn
- How Python handles type hints at runtime (and how it mostly does not)
typing.get_type_hints()for runtime type introspection- Limitations of
isinstancewith generic types - Runtime type checkers: beartype, typeguard
- Pydantic's validation model and how it differs from plain type hints
typing_extensionsand forward compatibility- Building a simple runtime type validator from scratch
- When to use static vs runtime checking
Prerequisites
- All previous lessons in this module (Generics, Protocol, ParamSpec, Overload)
- Experience with Pydantic basics from the Intermediate course
- Understanding of JSON parsing and API data validation
- Familiarity with decorators
Part 1 -- How Python Handles Type Hints at Runtime
Type Hints Are Metadata, Not Enforcement
Python stores type hints as annotations but does not enforce them:
def greet(name: str) -> str:
return f"Hello, {name}"
# These all work at runtime -- no TypeError:
greet(42) # "Hello, 42"
greet(None) # "Hello, None"
greet([1, 2, 3]) # "Hello, [1, 2, 3]"
Annotations are stored in the __annotations__ attribute:
print(greet.__annotations__)
# {'name': <class 'str'>, 'return': <class 'str'>}
get_type_hints() -- The Right Way to Read Annotations
Never read __annotations__ directly. Use typing.get_type_hints():
from typing import get_type_hints, Optional
class User:
name: str
age: int
email: Optional[str] = None
# __annotations__ may contain string annotations (forward refs):
print(User.__annotations__)
# {'name': <class 'str'>, 'age': <class 'int'>, 'email': 'Optional[str]'}
# Note: email might be a string if `from __future__ import annotations` is used
# get_type_hints() resolves forward references:
print(get_type_hints(User))
# {'name': <class 'str'>, 'age': <class 'int'>, 'email': typing.Optional[str]}
__annotations__ can contain:
- Actual type objects (
str,int) - String literals (forward references, or all annotations when using
from __future__ import annotations) typingspecial forms (Optional[str],Union[int, str])
get_type_hints() resolves all string annotations into actual types. Always use it instead of raw __annotations__.
The from future import annotations Effect
from __future__ import annotations
class Node:
def __init__(self, value: int, children: list[Node]) -> None:
self.value = value
self.children = children
# With the future import, ALL annotations become strings:
print(Node.__init__.__annotations__)
# {'value': 'int', 'children': 'list[Node]', 'return': 'None'}
# get_type_hints() resolves them:
from typing import get_type_hints
print(get_type_hints(Node.__init__))
# {'value': <class 'int'>, 'children': list[Node], 'return': <class 'NoneType'>}
Part 2 -- isinstance Limitations with Generics
What Works
# Concrete types work fine:
isinstance(42, int) # True
isinstance("hello", str) # True
isinstance([1, 2], list) # True
# Tuple of types for unions:
isinstance(42, (int, str)) # True
What Does Not Work
from typing import List, Dict, Optional
# Generic types CANNOT be used with isinstance:
# isinstance([1, 2], list[int]) # TypeError!
# isinstance({}, dict[str, int]) # TypeError!
# You can check the container type, but not the element type:
isinstance([1, 2], list) # True -- but is it list[int]? list[str]? Unknown.
Why Generic isinstance Fails
Generic types like list[int] are not real classes at runtime. They are parameterized aliases created by __class_getitem__:
print(type(list[int])) # <class 'types.GenericAlias'>
print(type(list)) # <class 'type'>
# list[int] is not list:
print(list[int] is list) # False
print(list[int] == list) # False
# But instances are still plain lists:
x = [1, 2, 3]
print(type(x)) # <class 'list'> -- no type parameter info
Workarounds for Generic Checks
from typing import get_args, get_origin
# Inspect generic aliases:
print(get_origin(list[int])) # <class 'list'>
print(get_args(list[int])) # (<class 'int'>,)
print(get_origin(dict[str, int])) # <class 'dict'>
print(get_args(dict[str, int])) # (<class 'str'>, <class 'int'>)
# Manual validation:
def is_list_of(obj: object, elem_type: type) -> bool:
if not isinstance(obj, list):
return False
return all(isinstance(item, elem_type) for item in obj)
print(is_list_of([1, 2, 3], int)) # True
print(is_list_of([1, "two", 3], int)) # False
Checking element types requires iterating through the entire collection. This is O(n) and can be expensive for large datasets. Consider whether you truly need to validate every element or if spot-checking or schema validation (Pydantic) is more appropriate.
Part 3 -- beartype: Fast Runtime Type Checking
What beartype Does
beartype is a near-zero-overhead runtime type checker. It wraps functions with type validation that runs at call time:
from beartype import beartype
@beartype
def process(name: str, scores: list[int], threshold: float = 0.5) -> bool:
return sum(scores) / len(scores) > threshold
process("Alice", [90, 85, 92], 88.0) # OK
process("Alice", [90, 85, 92]) # OK -- uses default threshold
process(42, [90, 85, 92]) # BeartypeCallHintParamViolation!
process("Alice", ["90", "85"]) # BeartypeCallHintParamViolation!
beartype's O(1) Validation Strategy
Unlike other runtime checkers, beartype does not validate every element in a container. It uses a randomized O(1) strategy:
from beartype import beartype
@beartype
def sum_scores(scores: list[int]) -> int:
return sum(scores)
# beartype checks a RANDOM element, not all elements:
sum_scores([1, 2, 3, "four", 5]) # Might pass or fail depending on which element is checked!
beartype's O(1) strategy means it can miss type violations in large collections. It checks a random sample of elements for performance. For guaranteed validation of all elements, use Pydantic or typeguard instead.
beartype Configuration
from beartype import beartype, BeartypeConf
# Strict mode: check more thoroughly
@beartype(conf=BeartypeConf(is_debug=True))
def strict_fn(data: list[int]) -> int:
return sum(data)
# Apply beartype to an entire class:
@beartype
class UserService:
def create(self, name: str, email: str) -> dict[str, str]:
return {"name": name, "email": email}
def delete(self, user_id: int) -> bool:
return True
svc = UserService()
Part 4 -- typeguard: Full Runtime Validation
typeguard vs beartype
typeguard validates all elements in collections (O(n)), while beartype samples randomly (O(1)):
from typeguard import typechecked
@typechecked
def process(items: list[int]) -> int:
return sum(items)
process([1, 2, 3]) # OK
process([1, 2, "three"]) # TypeCheckError: item 2 is not an instance of int
# typeguard catches it every time because it checks ALL elements
typeguard Features
from typeguard import typechecked, check_type
# Function-level checking:
@typechecked
def merge(a: dict[str, int], b: dict[str, int]) -> dict[str, int]:
return {**a, **b}
merge({"x": 1}, {"y": 2}) # OK
merge({"x": "1"}, {"y": 2}) # TypeCheckError
# Standalone type checking:
check_type([1, 2, 3], list[int]) # OK -- no error
check_type([1, "2", 3], list[int]) # TypeCheckError
# Check Union types:
check_type(42, int | str) # OK
check_type(3.14, int | str) # TypeCheckError
typeguard with Complex Types
from typeguard import typechecked
from typing import TypedDict, Sequence
class Address(TypedDict):
street: str
city: str
zip_code: str
@typechecked
def format_address(addr: Address) -> str:
return f"{addr['street']}, {addr['city']} {addr['zip_code']}"
format_address({"street": "123 Main", "city": "NYC", "zip_code": "10001"}) # OK
format_address({"street": "123 Main", "city": "NYC"}) # TypeCheckError: missing key
Performance Comparison
| Tool | Strategy | Overhead | Best For |
|---|---|---|---|
| beartype | O(1) random sampling | Near zero | Hot paths, production code |
| typeguard | O(n) full validation | Moderate | Tests, development, boundaries |
| Pydantic | O(n) with coercion | Higher | API input validation, serialization |
Part 5 -- Pydantic's Runtime Validation Model
Pydantic vs Type Hints
Pydantic takes a fundamentally different approach. Instead of decorating functions, you define models that validate data on construction:
from pydantic import BaseModel, EmailStr, Field
class CreateUserRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
age: int = Field(ge=0, le=150)
email: EmailStr
# Validation happens on construction:
# OK -- all valid
# Invalid data raises ValidationError:
try:
bad = CreateUserRequest(name="", age=-5, email="not-an-email")
except Exception as e:
print(e)
# 3 validation errors:
# name: String should have at least 1 character
# age: Input should be greater than or equal to 0
# email: value is not a valid email address
Type Coercion vs Strict Validation
By default, Pydantic coerces types:
from pydantic import BaseModel
class Config(BaseModel):
port: int
debug: bool
name: str
# Pydantic coerces compatible types:
c = Config(port="8080", debug="true", name=42)
print(c.port) # 8080 (int, coerced from str)
print(c.debug) # True (bool, coerced from str)
print(c.name) # "42" (str, coerced from int)
For strict validation without coercion:
from pydantic import BaseModel, ConfigDict
class StrictConfig(BaseModel):
model_config = ConfigDict(strict=True)
port: int
debug: bool
name: str
# Strict mode rejects type mismatches:
try:
StrictConfig(port="8080", debug=True, name="app")
except Exception as e:
print(e) # port: Input should be a valid integer
Pydantic for API Boundary Validation
from pydantic import BaseModel, Field, field_validator
from typing import Literal
class CoursePayload(BaseModel):
title: str = Field(min_length=3, max_length=200)
description: str = Field(default="")
price: float = Field(ge=0)
tier: Literal["free", "paid"]
@field_validator("title")
@classmethod
def title_must_be_titlecase(cls, v: str) -> str:
if not v[0].isupper():
raise ValueError("Title must start with uppercase letter")
return v
# Simulated API endpoint:
def create_course(raw_json: str) -> CoursePayload:
"""Validate and parse incoming JSON."""
return CoursePayload.model_validate_json(raw_json)
course = create_course('{"title": "Python Advanced", "price": 99.0, "tier": "paid"}')
print(course.title) # "Python Advanced"
print(course.tier) # "paid"
When to Use What
Part 6 -- typing_extensions
What typing_extensions Provides
typing_extensions is a first-party package that backports newer typing features to older Python versions:
# Instead of version-checking:
import sys
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
# Just use typing_extensions consistently:
from typing_extensions import (
Self, # 3.11+
TypeIs, # 3.13+
ParamSpec, # 3.10+
TypeVarTuple, # 3.11+
Unpack, # 3.11+
TypeAlias, # 3.10+
override, # 3.12+
assert_never, # 3.11+
)
Best practice: Always import from typing_extensions for features newer than your minimum supported Python version. It is a lightweight package with no dependencies, maintained by the typing team.
In your pyproject.toml:
[project]
dependencies = ["typing_extensions>=4.0"]
Useful typing_extensions Features
from typing_extensions import TypedDict, Required, NotRequired
class UserProfile(TypedDict, total=False):
name: Required[str] # Must always be present
email: Required[str] # Must always be present
phone: NotRequired[str] # Optional key
bio: NotRequired[str] # Optional key
# Required fields must be provided:
# profile: UserProfile = {"name": "Alice"} # ERROR: missing 'email'
from typing_extensions import override
class Base:
def process(self, data: str) -> int:
return len(data)
class Child(Base):
@override
def process(self, data: str) -> int: # OK
return len(data) * 2
@override
def prcess(self, data: str) -> int: # ERROR: no such method in Base (typo!)
return 0
Part 7 -- Building a Runtime Validator
Let us build a simple runtime type validator to understand the internals:
from typing import (
get_type_hints, get_args, get_origin,
Union, Any,
)
import types
def validate_type(value: object, expected: type) -> bool:
"""Validate a value against a type hint at runtime."""
# Handle None/NoneType
if expected is type(None):
return value is None
# Handle Any
if expected is Any:
return True
# Handle Union (int | str, Optional[int], etc.)
origin = get_origin(expected)
if origin is Union or origin is types.UnionType:
args = get_args(expected)
return any(validate_type(value, arg) for arg in args)
# Handle list[T]
if origin is list:
if not isinstance(value, list):
return False
args = get_args(expected)
if not args:
return True
elem_type = args[0]
return all(validate_type(item, elem_type) for item in value)
# Handle dict[K, V]
if origin is dict:
if not isinstance(value, dict):
return False
args = get_args(expected)
if not args or len(args) < 2:
return True
key_type, val_type = args
return all(
validate_type(k, key_type) and validate_type(v, val_type)
for k, v in value.items()
)
# Handle tuple[T, ...]
if origin is tuple:
if not isinstance(value, tuple):
return False
args = get_args(expected)
if not args:
return True
if len(args) == 2 and args[1] is Ellipsis:
return all(validate_type(item, args[0]) for item in value)
if len(args) != len(value):
return False
return all(validate_type(v, t) for v, t in zip(value, args))
# Handle concrete types
if isinstance(expected, type):
return isinstance(value, expected)
return True # Fall back to accepting unknown types
# Usage:
print(validate_type(42, int)) # True
print(validate_type("hello", int)) # False
print(validate_type([1, 2, 3], list[int])) # True
print(validate_type([1, "2", 3], list[int])) # False
print(validate_type({"a": 1}, dict[str, int])) # True
print(validate_type(None, int | None)) # True
print(validate_type((1, "a"), tuple[int, str])) # True
Making It a Decorator
from typing import get_type_hints, Callable, ParamSpec, TypeVar
import functools
import inspect
P = ParamSpec("P")
R = TypeVar("R")
def runtime_check(func: Callable[P, R]) -> Callable[P, R]:
"""Decorator that validates argument types at runtime."""
hints = get_type_hints(func)
sig = inspect.signature(func)
params = list(sig.parameters.keys())
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# Validate positional args
for i, (value, param_name) in enumerate(zip(args, params)):
if param_name in hints and param_name != "return":
expected = hints[param_name]
if not validate_type(value, expected):
raise TypeError(
f"Argument '{param_name}' expected {expected}, "
f"got {type(value).__name__}: {value!r}"
)
# Validate keyword args
for param_name, value in kwargs.items():
if param_name in hints:
expected = hints[param_name]
if not validate_type(value, expected):
raise TypeError(
f"Argument '{param_name}' expected {expected}, "
f"got {type(value).__name__}: {value!r}"
)
result = func(*args, **kwargs)
# Validate return type
if "return" in hints:
if not validate_type(result, hints["return"]):
raise TypeError(
f"Return value expected {hints['return']}, "
f"got {type(result).__name__}: {result!r}"
)
return result
return wrapper
@runtime_check
def create_user(name: str, age: int, tags: list[str] | None = None) -> dict[str, object]:
return {"name": name, "age": age, "tags": tags or []}
create_user("Alice", 30) # OK
create_user("Alice", 30, tags=["admin", "user"]) # OK
# create_user("Alice", "thirty") # TypeError!
# create_user("Alice", 30, tags=[1, 2]) # TypeError!
Part 8 -- Static vs Runtime: When to Use What
The Tradeoff Matrix
| Concern | Static (mypy/pyright) | Runtime (beartype/Pydantic) |
|---|---|---|
| Performance cost | Zero at runtime | Validation overhead |
| Catches bugs in | Your code | Data from outside |
| Coverage | Internal logic | System boundaries |
| False positives | Possible (strict mode) | None (validates actual values) |
| Maintenance | Type annotations only | Annotations + validation code |
The Boundary Strategy
# BOUNDARY: External data enters your system -- validate here
from pydantic import BaseModel
class IncomingPayload(BaseModel):
"""Validated at the API boundary."""
name: str
age: int
def api_endpoint(raw_json: str) -> dict[str, object]:
payload = IncomingPayload.model_validate_json(raw_json) # Runtime check
return process_user(payload.name, payload.age) # Type-safe from here
# INTERIOR: Internal functions use static types only
def process_user(name: str, age: int) -> dict[str, object]:
"""No runtime checks needed -- caller is already validated."""
return {"name": name, "category": "adult" if age >= 18 else "minor"}
Validate at boundaries, trust in interiors.
- Boundaries: API endpoints, CLI arguments, config files, database results, file I/O, IPC -- use Pydantic or typeguard
- Interiors: Internal function calls, class methods, module-level logic -- use static type hints only
This gives you the safety of runtime validation where data is untrusted, without the performance cost of checking every internal function call.
Testing with Runtime Checks
from typeguard import typechecked
import pytest
# Enable typeguard for all tests:
# In conftest.py:
# from typeguard import install_import_hook
# install_import_hook("mypackage")
# Or per-test:
@typechecked
def process(data: list[int]) -> float:
return sum(data) / len(data)
def test_process() -> None:
assert process([1, 2, 3]) == 2.0
with pytest.raises(TypeError):
process(["1", "2", "3"]) # Caught by typeguard
Key Takeaways
- Python does not enforce type hints at runtime -- they are metadata only
- Use
typing.get_type_hints()instead of__annotations__to read resolved type information isinstancedoes not work with generic types (list[int],dict[str, str]) -- only with base container types- beartype provides O(1) runtime checking via random sampling -- fast but not exhaustive
- typeguard provides O(n) full validation -- thorough but slower
- Pydantic validates, coerces, and serializes data -- the standard for API boundaries
- typing_extensions backports newer typing features to older Python versions
- Validate at boundaries, trust in interiors: use runtime checks where data enters your system, static checks everywhere else
get_origin()andget_args()let you introspect generic types programmatically- Building a runtime validator teaches you how the type system works under the hood
Graded Practice Challenges
Level 1 -- Predict the Output
Question 1: What does this print?
from typing import get_type_hints
class User:
name: str
age: "int"
hints = get_type_hints(User)
print(hints["age"] is int)
Answer
It prints True. get_type_hints() resolves the string "int" into the actual int type. This is exactly why you should use get_type_hints() instead of reading __annotations__ directly -- forward references are resolved.
Question 2: Does this raise an error?
from beartype import beartype
@beartype
def total(values: list[int]) -> int:
return sum(values)
total([1, 2, "three", 4, 5])
Answer
It might or might not raise an error. beartype uses O(1) random sampling -- it checks a random element from the list. If it happens to check "three", it raises BeartypeCallHintParamViolation. If it checks any of the integers, it passes. This is by design for performance. For guaranteed checking, use typeguard or Pydantic.
Question 3: What is the output?
from typing import get_origin, get_args
t = dict[str, list[int]]
print(get_origin(t))
print(get_args(t))
print(get_origin(get_args(t)[1]))
print(get_args(get_args(t)[1]))
Answer
<class 'dict'>
(<class 'str'>, list[int])
<class 'list'>
(<class 'int'>,)
get_origin returns the base container type. get_args returns the type parameters. You can recursively inspect nested generics by calling get_origin/get_args on the args.
Level 2 -- Debug and Fix
This runtime validator has a bug with Optional types. Find and fix it:
from typing import get_type_hints, Optional, Union, get_origin, get_args
import types
def check(value: object, expected: type) -> bool:
origin = get_origin(expected)
if origin is Union:
return any(check(value, arg) for arg in get_args(expected))
if isinstance(expected, type):
return isinstance(value, expected)
return True
def validate_function_args(func, *args, **kwargs):
hints = get_type_hints(func)
import inspect
params = list(inspect.signature(func).parameters.keys())
for i, (val, name) in enumerate(zip(args, params)):
if name in hints and name != "return":
if not check(val, hints[name]):
raise TypeError(f"{name}: expected {hints[name]}, got {type(val)}")
@validate_function_args
def create_item(name: str, description: Optional[str] = None) -> dict:
return {"name": name, "description": description}
create_item("Widget", None) # Should work but might not
Answer
Two issues:
-
Missing
types.UnionTypehandling: In Python 3.10+,str | Noneusestypes.UnionTyperather thantyping.Union.Optional[str]isUnion[str, None]which usestyping.Union, so it might work. But if someone writesstr | None,get_originreturnstypes.UnionType, nottyping.Union. -
NoneTypehandling: WhenvalueisNone,isinstance(None, type(None))works, buttype(None)appears in Union args as<class 'NoneType'>. This actually works withisinstance. -
validate_function_argsis used as a decorator but is not implemented as one: It takesfunc, *args, **kwargs-- this would be called asvalidate_function_args(create_item, "Widget", None), not as a decorator.
Fix:
from typing import get_type_hints, Optional, Union, get_origin, get_args
import types
import functools
import inspect
def check(value: object, expected: type) -> bool:
# Handle NoneType
if expected is type(None):
return value is None
origin = get_origin(expected)
# Handle both Union and | syntax
if origin is Union or origin is types.UnionType:
return any(check(value, arg) for arg in get_args(expected))
if isinstance(expected, type):
return isinstance(value, expected)
return True
def validate_function_args(func):
hints = get_type_hints(func)
params = list(inspect.signature(func).parameters.keys())
@functools.wraps(func)
def wrapper(*args, **kwargs):
for i, (val, name) in enumerate(zip(args, params)):
if name in hints and name != "return":
if not check(val, hints[name]):
raise TypeError(f"{name}: expected {hints[name]}, got {type(val)}")
return func(*args, **kwargs)
return wrapper
Level 3 -- Design Challenge
Build a @validated decorator that works like a simplified Pydantic for function arguments:
@validated
def register(
name: str,
age: int,
email: str,
tags: list[str] = [],
metadata: dict[str, int] | None = None,
) -> dict[str, object]:
return {"name": name, "age": age}
# Should work:
# Should raise clear errors:
Requirements:
- Use
get_type_hints()to extract types - Validate nested generics (
list[str],dict[str, int]) - Handle
UnionandNonetypes - Preserve function signature with
ParamSpec - Include the parameter name and position in error messages
Hint
Build on the validate_type function from Part 7, enhance it with error messages that include the path to the failing value (e.g., "tags[2]"), and wrap it in a ParamSpec-preserving decorator. Use get_origin/get_args to recursively inspect generic types.
What's Next
In the final lesson of this module, Static Analysis in Practice, we cover configuring mypy and pyright for real projects, strict mode vs gradual typing, type stubs, CI integration, and migrating untyped codebases.
