How to Validate Data in Python with Typing Hints

Build a runtime validation helper that checks values against Python type hints like Optional, list, and basic types.

Medium Python 3.10+ Aug 9, 2026 Testing & modern typing 14 views 0 copies

Python code

72 lines
Python 3.10+
from typing import Any, Optional, Union, TypeVar, get_origin, get_args

T = TypeVar("T")

def validate(value: Any, expected_type: type) -> Optional[str]:
    """Returns an error message if value doesn't match expected_type, else None."""
    # Handle Optional[...] types
    origin = get_origin(expected_type)
    if origin is Union:
        args = get_args(expected_type)
        if type(None) in args and value is None:
            return None
        # Check if value matches any of the Union members
        for arg in args:
            if arg is not type(None) and validate(value, arg) is None:
                return None
        return f"Value {value!r} does not match any of {expected_type}"
    
    # Handle list[...], dict[...], etc.
    if origin is not None:
        if not isinstance(value, origin):
            return f"Expected {origin.__name__}, got {type(value).__name__}"
        # Validate container elements
        args = get_args(expected_type)
        if origin is list and args:
            for item in value:
                error = validate(item, args[0])
                if error:
                    return f"List element error: {error}"
        return None
    
    # Basic type check
    if expected_type is Any:
        return None
    if not isinstance(value, expected_type):
        return f"Expected {expected_type.__name__}, got {type(value).__name__}"
    return None


def validate_dict(data: dict, schema: dict) -> list[str]:
    """Validates a dict against a schema of {field: type}."""
    errors = []
    for field, expected_type in schema.items():
        if field not in data:
            errors.append(f"Missing required field: {field}")
            continue
        error = validate(data[field], expected_type)
        if error:
            errors.append(f"Field '{field}': {error}")
    return errors


if __name__ == "__main__":
    user_schema = {
        "name": str,
        "age": int,
        "email": Optional[str],
        "tags": list[str],
    }
    
    test_users = [
        {"name": "Alice", "age": 30, "email": "alice@example.com", "tags": ["admin"]},
        {"name": "Bob", "age": "thirty", "tags": [1, 2]},
        {"name": "Charlie", "age": 25},
    ]
    
    for user in test_users:
        errors = validate_dict(user, user_schema)
        if errors:
            print(f"User {user.get('name', '?')}: INVALID -> {errors}")
        else:
            print(f"User {user['name']}: VALID")

Output

stdout
User Alice: VALID
User Bob: INVALID -> ['Field \'age\': Expected int, got str', 'List element error: Expected str, got int']
User Charlie: INVALID -> ['Missing required field: email', "Field 'tags': Expected list, got NoneType"]

How it works

The validate function uses get_origin and get_args from typing to inspect complex type hints at runtime. For Optional types (which are Union with None), it first accepts None, then tries each non-None member. For containers like list, it checks the container type first, then recursively validates each element. The validate_dict function iterates through a schema dictionary, checking required fields and validating each value, collecting all errors rather than stopping at the first one. This pattern provides safe, readable validation for small to medium datasets without needing external libraries.

Common mistakes

  • Using isinstance with generic types like list[str] directly, which raises TypeError
  • Forgetting that Optional[str] is actually Union[str, None] and needs special handling
  • Not recursing into container elements, so list[int] accepts ['a', 'b']
  • Stopping at the first validation error instead of collecting all issues

Variations

  1. Use the pydantic library for automatic validation with dataclasses or models
  2. Replace the recursive function with a match statement on get_origin result for clearer branching

Real-world use cases

  • Validating webhook payloads before processing payment events in a Django API.
  • Sanitizing user-submitted JSON in a CLI tool before writing to a database.
  • Checking configuration dictionary values at service startup to fail fast with clear messages.

Sponsored

Run this sample

Open the browser IDE to tweak the example and see results without installing anything.

Open editor

More from Testing & modern typing

Related tutorials and quizzes for this topic.