Skip to main content

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:
data: UserPayload = {"name": "Alice", "age": 30, "email": "[email protected]"}
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 isinstance with generic types
  • Runtime type checkers: beartype, typeguard
  • Pydantic's validation model and how it differs from plain type hints
  • typing_extensions and 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]}
danger

__annotations__ can contain:

  • Actual type objects (str, int)
  • String literals (forward references, or all annotations when using from __future__ import annotations)
  • typing special 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
note

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!
danger

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()
svc.create("Alice", "[email protected]") # OK
svc.create(42, "[email protected]") # BeartypeCallHintParamViolation!

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

ToolStrategyOverheadBest For
beartypeO(1) random samplingNear zeroHot paths, production code
typeguardO(n) full validationModerateTests, development, boundaries
PydanticO(n) with coercionHigherAPI 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:
user = CreateUserRequest(name="Alice", age=30, email="[email protected]")
# 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+
)
tip

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", "email": "[email protected]"} # OK
# 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

ConcernStatic (mypy/pyright)Runtime (beartype/Pydantic)
Performance costZero at runtimeValidation overhead
Catches bugs inYour codeData from outside
CoverageInternal logicSystem boundaries
False positivesPossible (strict mode)None (validates actual values)
MaintenanceType annotations onlyAnnotations + 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"}
tip

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
  • isinstance does 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() and get_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:

  1. Missing types.UnionType handling: In Python 3.10+, str | None uses types.UnionType rather than typing.Union. Optional[str] is Union[str, None] which uses typing.Union, so it might work. But if someone writes str | None, get_origin returns types.UnionType, not typing.Union.

  2. NoneType handling: When value is None, isinstance(None, type(None)) works, but type(None) appears in Union args as <class 'NoneType'>. This actually works with isinstance.

  3. validate_function_args is used as a decorator but is not implemented as one: It takes func, *args, **kwargs -- this would be called as validate_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:
register("Alice", 30, "[email protected]")
register("Alice", 30, "[email protected]", tags=["admin"], metadata={"score": 100})

# Should raise clear errors:
register(42, 30, "[email protected]") # "name: expected str, got int"
register("Alice", "30", "[email protected]") # "age: expected int, got str"
register("Alice", 30, "[email protected]", tags=[1, 2]) # "tags: element 0 expected str, got int"

Requirements:

  1. Use get_type_hints() to extract types
  2. Validate nested generics (list[str], dict[str, int])
  3. Handle Union and None types
  4. Preserve function signature with ParamSpec
  5. 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.

© 2026 EngineersOfAI. All rights reserved.