Validate¶
Zero-dependency runtime validator for TypedDict and dataclass types with JSON Schema generation -- stdlib only, Python 3.10+.
Replaces:
pydantic(validation),cattrs,typeguard,marshmallow
Overview¶
The Validate module provides runtime validation of arbitrary data against stdlib type annotations (TypedDict, dataclass, Annotated constraints) and generates JSON Schema from the same type definitions -- all without any third-party dependencies.
| File | Description | Dependencies |
|---|---|---|
validate.py |
Pure Python implementation | None (stdlib only: typing, dataclasses, re) |
The module supports TypedDict and dataclass validation, Annotated constraints (Gt, Ge, Lt, Le, MinLen, MaxLen, Match, Predicate), FieldValidator for per-field transformation and validation, model_validator for cross-field checks, Union/Optional/Literal types, discriminated unions, nested structures, optional type coercion, structured error collection with dotted field paths, and JSON Schema generation.
v0.4.1 Performance & Correctness
Internal field-introspection helpers (_typeddict_fields, _dataclass_fields, _find_discriminator) are now cached with functools.lru_cache, avoiding redundant get_type_hints() calls and providing 8-10x speedup for complex nested TypedDict structures. A new _strip_required() helper correctly unwraps Required[T]/NotRequired[T] wrappers so that discriminated union matching works reliably when union members use these annotations.
How to Use in Your Project¶
Just copy the single .py file into your project:
Then import directly:
from validate import validate, json_schema, ValidationError
from validate import Gt, Ge, Lt, Le, MinLen, MaxLen, Match, Predicate
from validate import FieldValidator, model_validator
typing_extensions
On Python 3.10, typing_extensions is needed for Required/NotRequired support. On 3.11+ everything comes from stdlib typing.
Usage Examples¶
Basic TypedDict Validation¶
from typing import TypedDict
from validate import validate, ValidationError
class User(TypedDict):
name: str
age: int
# Valid data passes through
validate({"name": "Alice", "age": 30}, User)
# Invalid data raises ValidationError with all errors
try:
validate({"name": 123, "age": "thirty"}, User)
except ValidationError as e:
for err in e.errors:
print(f"{err.path}: expected {err.expected}, got {err.actual}")
# name: expected str, got int
# age: expected int, got str
Annotated Constraints¶
from typing import Annotated, TypedDict
from validate import validate, Gt, Ge, Le, MinLen, MaxLen, Match
class Product(TypedDict):
name: Annotated[str, MinLen(1), MaxLen(100)]
price: Annotated[float, Gt(0)]
quantity: Annotated[int, Ge(0), Le(10000)]
sku: Annotated[str, Match(r"[A-Z]{2}-\d{4}")]
validate({
"name": "Widget",
"price": 9.99,
"quantity": 100,
"sku": "AB-1234",
}, Product)
Nested Structures¶
from typing import TypedDict
from validate import validate
class Address(TypedDict):
street: str
city: str
zip_code: str
class UserProfile(TypedDict):
name: str
address: Address
# Errors include dotted paths like "address.street"
validate({
"name": "Alice",
"address": {"street": "123 Main St", "city": "NYC", "zip_code": "10001"},
}, UserProfile)
Optional and Union Types¶
from typing import Literal, Optional, TypedDict, Union
from validate import validate
class TextContent(TypedDict):
kind: Literal["text"]
text: str
class ImageContent(TypedDict):
kind: Literal["image"]
url: str
# Discriminated union -- dispatches via shared "kind" field
Content = Union[TextContent, ImageContent]
validate({"kind": "text", "text": "hello"}, Content)
validate({"kind": "image", "url": "https://..."}, Content)
Required / NotRequired Fields¶
from typing import TypedDict
from typing_extensions import NotRequired, Required
from validate import validate
class Config(TypedDict):
host: str
port: Required[int]
debug: NotRequired[bool]
validate({"host": "localhost", "port": 8080}, Config) # ok
validate({"host": "localhost", "port": 8080, "debug": True}, Config) # ok
Dataclass Validation¶
import dataclasses
from validate import validate
@dataclasses.dataclass
class Point:
x: float
y: float
z: float = 0.0
# Validates dict data against the dataclass definition
validate({"x": 1.0, "y": 2.0}, Point) # ok, z has default
validate({"x": 1.0, "y": 2.0, "z": 3.0}, Point) # ok
Type Coercion¶
from validate import validate
# Strict mode (default) -- rejects type mismatches
# validate("42", int) # raises ValidationError
# Coercion mode -- converts compatible types
result = validate("42", int, coerce=True) # returns 42
result = validate("3.14", float, coerce=True) # returns 3.14
JSON Schema Generation¶
from typing import Annotated, TypedDict
from validate import json_schema, Gt, MinLen, MaxLen
class Item(TypedDict):
name: Annotated[str, MinLen(1), MaxLen(100)]
price: Annotated[float, Gt(0)]
tags: list[str]
schema = json_schema(Item)
# {
# "type": "object",
# "properties": {
# "name": {"type": "string", "minLength": 1, "maxLength": 100},
# "price": {"type": "number", "exclusiveMinimum": 0},
# "tags": {"type": "array", "items": {"type": "string"}}
# },
# "required": ["name", "price", "tags"],
# "title": "Item"
# }
Custom Predicate¶
from typing import Annotated
from validate import validate, Predicate
EvenInt = Annotated[int, Predicate(lambda x: x % 2 == 0, "must be even")]
validate(4, EvenInt) # ok
validate(3, EvenInt) # raises: Constraint must be even failed
Field Validators¶
FieldValidator is like Predicate but can transform the value and raises exceptions to signal failure (instead of returning bool). Use it with Annotated for per-field validation and normalization.
from typing import Annotated, TypedDict
from validate import validate, FieldValidator, MinLen
def strip_lower(v: str) -> str:
"""Strip whitespace and lowercase."""
v = v.strip().lower()
if not v:
raise ValueError("must not be empty after stripping")
return v
class User(TypedDict):
username: Annotated[str, FieldValidator(strip_lower, "strip_lower"), MinLen(2)]
age: int
# FieldValidator transforms the value, then MinLen checks the result
validate({"username": " ALICE ", "age": 30}, User)
Multiple FieldValidators compose left-to-right -- the output of one feeds into the next:
Cleaned = Annotated[
str,
FieldValidator(lambda v: v.strip(), "strip"),
FieldValidator(lambda v: v.lower(), "lower"),
]
validate(" HELLO ", Cleaned) # returns "hello"
Model Validators¶
model_validator registers a cross-field validator on a TypedDict or dataclass type. It runs after all field validation passes.
from typing import TypedDict
from validate import validate, model_validator
class RegisterForm(TypedDict):
password: str
confirm: str
@model_validator(RegisterForm)
def passwords_match(data: dict) -> dict:
if data["password"] != data["confirm"]:
raise ValueError("passwords do not match")
return data
validate({"password": "secret", "confirm": "secret"}, RegisterForm) # ok
validate({"password": "secret", "confirm": "wrong"}, RegisterForm) # raises
Key behaviors:
- Model validators only run if all field validations pass (no type errors, no missing fields)
- Validators receive the full data dict and should return it (possibly modified)
- Raise
ValueErrororAssertionErrorto signal failure - Multiple validators on the same type run in registration order
API Reference¶
validate(data, tp, *, coerce=False)¶
Validate data against a type annotation.
Parameters:
| Name | Type | Default | Description |
|---|---|---|---|
data |
Any |
-- | The data to validate. |
tp |
type |
-- | A TypedDict class, dataclass class, or any type annotation. |
coerce |
bool |
False |
If True, attempt type coercion (e.g. str to int). |
Returns: The validated (and possibly coerced) data.
Raises: ValidationError with all errors collected.
json_schema(tp, *, title=None)¶
Generate a JSON Schema dict from a type annotation.
Parameters:
| Name | Type | Default | Description |
|---|---|---|---|
tp |
type |
-- | A TypedDict class, dataclass class, or any type annotation. |
title |
str \| None |
None |
Optional title. Defaults to the type's __name__. |
Returns: A JSON Schema dict (draft 2020-12 compatible subset).
Constraint Annotations¶
All constraints are frozen dataclasses usable with typing.Annotated.
| Constraint | Meaning | JSON Schema |
|---|---|---|
Gt(val) |
> val |
exclusiveMinimum |
Ge(val) |
>= val |
minimum |
Lt(val) |
< val |
exclusiveMaximum |
Le(val) |
<= val |
maximum |
MinLen(val) |
len >= val |
minLength / minItems |
MaxLen(val) |
len <= val |
maxLength / maxItems |
Match(pattern) |
re.fullmatch |
pattern |
Predicate(fn, desc) |
Custom check | (not mapped) |
FieldValidator(fn, desc) |
Custom transform + validate | (not mapped) |
model_validator(tp)¶
Decorator to register a cross-field validator on a TypedDict or dataclass type.
Parameters:
| Name | Type | Description |
|---|---|---|
tp |
type |
The TypedDict or dataclass type to attach the validator to. |
Returns: A decorator that registers the function and returns it unchanged.
The registered function receives the full data dict after all field validation passes. It should return the dict (possibly modified) or raise ValueError / AssertionError.
ValidationError¶
Raised when validation fails. Subclass of Exception.
| Attribute | Type | Description |
|---|---|---|
errors |
list[ErrorDetail] |
All validation errors found. |
ErrorDetail¶
Dataclass describing a single validation error.
| Field | Type | Description |
|---|---|---|
path |
str |
Dotted/bracketed path (e.g. "items[2].name"). |
expected |
str |
Expected type or constraint. |
actual |
str |
Actual type or value. |
message |
str |
Human-readable error message. |
Comparison with pydantic¶
| Feature | zerodep validate | pydantic v2 |
|---|---|---|
| Dependencies | None (stdlib only) | pydantic-core (Rust) |
| Type system | Enhances stdlib TypedDict/dataclass | Custom BaseModel |
| Validation | validate(data, TypedDict) |
Model.model_validate(data) |
| Constraints | Annotated[int, Gt(0)] |
Annotated[int, Field(gt=0)] |
| JSON Schema | json_schema(TypedDict) |
Model.model_json_schema() |
| Error collection | Yes (all errors) | Yes (all errors) |
| Discriminated unions | Auto-detected via Literal fields | Explicit Discriminator |
| Coercion | coerce=True flag |
Default behavior (strict mode opt-in) |
| Serialization | No | Yes |
| Custom validators | FieldValidator, model_validator, Predicate |
@field_validator, @model_validator |
| Performance | Pure Python (~3.3 us simple with caching; beats pydantic on bulk data) | Rust core (~0.6 us simple) |
| Implementation | Single file (~500 lines) | Package (Rust extension) |
When to use zerodep: You want zero dependencies, already use TypedDict/dataclass, and need validation + JSON Schema without adopting a new type system.
When to use pydantic: You need maximum performance, serialization, or pydantic's ecosystem (FastAPI, SQLModel, etc.).
Benchmark¶
Benchmarked against pydantic v2 across simple/nested/constrained validation and JSON Schema generation.
See Validate Benchmark for detailed results.