Annotation-native toolkit for type inspection, validation, and data modeling
- TypeCraft
Type annotations in Python are an expressive, structured description of data, but most of that information is discarded at runtime. TypeCraft is a toolkit for putting it back to work. It treats annotations as first-class data, providing a small, composable set of layers that build on each other:
- A typing layer that wraps an annotation into a rich, introspectable container, with
isinstance-like andissubclass-like checks that honor generics, unions, andLiteral[]. - A conversion layer that uses these annotations to drive validation (loose Python objects → typed Python objects) and serialization (typed Python objects → JSON-compatible primitives), with a registry of user-defined converters and full support for nested generics.
- A modeling layer (
BaseModel) that turns ordinary dataclasses into validated models with field- and type-level converters, aliases, and configurable behavior — without metaclass shenanigans. - A TOML extra that combines the modeling layer with
tomlkitto give a typed, mutable, round-trippable interface for TOML documents.
TypeCraft trades speed for precision: it matches against the full parameterized annotation (e.g. list[int] rather than just list) so converters and checks can dispatch on the exact shape of the data, generics and all.
The following compares TypeCraft against similar libraries.
| TypeCraft | Pydantic | cattrs | msgspec | |
|---|---|---|---|---|
Standalone isinstance/issubclass-like checks over generics, unions, Literal[] |
✅ | ❌ | ❌ | ❌ |
| Validation (typed-from-loose) | ✅ | ✅ | ✅ | ✅ |
| Serialization (JSON-compatible primitives) | ✅ | ✅ | ✅ | ✅ |
| Standalone validation / serialization (no model required) | ✅ validate / serialize |
✅ TypeAdapter |
✅ structure / unstructure |
✅ convert / to_builtins |
| Data models | ✅ BaseModel |
✅ BaseModel |
➖ uses attrs/dataclasses |
✅ Struct |
Plain @dataclass, no custom metaclass |
✅ | ❌ | ✅ | ❌ |
Inline converters via Annotated[] |
✅ | ✅ | ✅ | Meta constraints (no conversion) |
| Custom type-based converter registry | ✅ | ✅ converters | enc_hook/dec_hook |
|
| Round-trippable TOML (formatting-preserving) | ✅ | ❌ | ❌ | |
| Implementation | pure Python | Rust core | pure Python | C extension |
Install using pip:
pip install typecraftTo use the TOML extra:
pip install typecraft[toml]The Annotation class is the core typing primitive. It wraps any annotation (including aliases, generics, unions, Literal[], Annotated[], and callables) and exposes a uniform interface for inspecting and reasoning about it.
from typing import Literal
from typecraft import Annotation
# basic types
a = Annotation(int)
assert a.raw is int
assert a.concrete_type is int
# generic types
a = Annotation(list[int])
assert a.origin is list
assert a.concrete_type is list
assert a.arg_annotations[0].raw is int
# unions
a = Annotation(int | str)
assert a.is_union
assert [arg.raw for arg in a.arg_annotations] == [int, str]
# literals
a = Annotation(Literal["a", "b", "c"])
assert a.is_literal
assert a.args == ("a", "b", "c")
# type aliases are unwrapped
type IntList = list[int]
a = Annotation(IntList)
assert a.origin is list
assert a.arg_annotations[0].raw is intAnnotation instances are cached by identity, which makes recursive type aliases safe to traverse:
type RecursiveAlias = list[RecursiveAlias] | int
a = Annotation(RecursiveAlias)
list_ann, int_ann = a.arg_annotations
# the inner list[RecursiveAlias] is the same Annotation object as `a`
assert list_ann.arg_annotations[0] is aThe two most common questions about an annotation are "does this object satisfy it?" (an isinstance-like check) and "is this annotation narrower than that one?" (an issubclass-like check). TypeCraft exposes both, with full awareness of generics, unions, and Literal[]:
from typing import Any, Literal
from typecraft import is_instance, is_narrower
# check if an object satisfies an annotation
assert is_instance(1, int | str)
assert is_instance([1, 2, "3"], list[int | str])
assert not is_instance([1, 2, "3"], list[int])
assert is_instance("a", Literal["a", "b", "c"])
# check if one annotation is narrower (more specific) than another
assert is_narrower(int, int | str)
assert is_narrower(list[int], list[int | str])
assert is_narrower(Literal["a"], Literal["a", "b"])
# Any is both the top type and the bottom type
assert is_narrower(int, Any)
assert is_narrower(Any, int)These functions accept either a raw annotation or an Annotation instance, so they're equally usable in throwaway checks and in code that already has an Annotation in hand.
Annotation automatically splits Annotated[] into the underlying type and its extras, exposing both:
from dataclasses import dataclass
from typing import Annotated
from typecraft import Annotation
@dataclass
class Unit:
name: str
a = Annotation(Annotated[float, Unit("meters"), "positive"])
# the wrapped type
assert a.raw is float
assert a.concrete_type is float
# extras preserved as a tuple in declaration order
units = [e for e in a.extras if isinstance(e, Unit)]
assert units[0].name == "meters"
assert "positive" in a.extrasThis is the same machinery that the validation and serialization layers use to discover converters declared inline as Annotated[T, ...] extras (see below).
The validation and serialization layers are two faces of the same conversion engine. Both walk an annotation, dispatching to type-based converters at each level.
- Validation moves loose data (e.g. JSON, kwargs) towards a typed Python representation.
- Serialization moves a typed Python representation back to JSON-compatible primitives (
str,int,float,bool,None,list,dict).
In strict mode, validate() only accepts objects that already match the target annotation; it fails otherwise. With strict=False, builtin coercions kick in:
from typing import Annotated
from typecraft import validate
from typecraft.validating import ValidationParams
# strict mode (the default): no conversions
assert validate([1, 2, 3], list[int]) == [1, 2, 3]
# loose mode: builtin coercions
result = validate(["1", "2", 3], list[int], params=ValidationParams(strict=False))
assert result == [1, 2, 3]
# arbitrarily nested generics are walked recursively
result = validate(
[[("1", "2"), ("3", "4")], [("5", "6")]],
list[list[list[int]]],
params=ValidationParams(strict=False),
)
assert result == [[[1, 2], [3, 4]], [[5, 6]]]
# Annotated[] is transparent
result = validate(
["1", "2", "3"],
Annotated[list[int], "positive integers"],
params=ValidationParams(strict=False),
)
assert result == [1, 2, 3]When validation fails, all errors found in the object tree are aggregated into a single ValidationError with a path-aware message:
from typecraft import validate, ValidationError
try:
validate([1, 2, "3"], list[str | float])
except ValidationError as e:
print(e)2 validation errors for list[str | float]
[0]=1: int -> str | float: TypeError
Errors during union member conversion:
str: No matching converters
float: No matching converters
[1]=2: int -> str | float: TypeError
Errors during union member conversion:
str: No matching converters
float: No matching converters
serialize() walks an object and produces a JSON-compatible value: str, int, float, bool, None, or a list/dict of the same. Builtin types like tuple, set, date, and datetime are converted automatically:
import datetime
from typecraft import serialize
assert serialize((1, 2, 3)) == [1, 2, 3]
assert sorted(serialize({1, 2, 3})) == [1, 2, 3]
assert serialize({"a": [1, 2], "b": [3, 4]}) == {"a": [1, 2], "b": [3, 4]}
# datetimes are serialized to ISO-8601 strings
assert serialize(datetime.date(2026, 1, 1)) == "2026-01-01"By default, the source type is inferred from the object. Pass source_type to influence dispatch. For example, when a fixed-length tuple[int, str] should be matched by a converter declared on that exact type rather than the generic tuple[Any, ...].
The conversion engine is driven by a registry of converters. A TypeValidator is a function (or callable) that converts an object of one type to another, paired with declarative match rules:
from typecraft import validate
from typecraft.validating import TypeValidator
class Celsius:
degrees: float
def __init__(self, degrees: float):
self.degrees = degrees
# convert from float to Celsius
celsius_validator = TypeValidator(float, Celsius, func=lambda d: Celsius(d))
result = validate(20.0, Celsius, celsius_validator)
assert isinstance(result, Celsius)
assert result.degrees == 20.0Converters work just as well on parameterized types:
from typecraft import validate
from typecraft.validating import TypeValidator
# only convert lists of positive ints
positive_validator = TypeValidator(
list[int],
list[str],
func=lambda obj: [str(o) for o in obj],
predicate_func=lambda obj: all(o > 0 for o in obj),
)
assert validate([1, 2, 3], list[str], positive_validator) == ["1", "2", "3"]Validators can be passed individually to validate() or grouped into a TypeValidatorRegistry for reuse. The same applies symmetrically to TypeSerializer and TypeSerializerRegistry.
Converters can also be attached inline using Annotated[]:
from typing import Annotated
from typecraft import serialize, validate
from typecraft.validating import TypeValidator
from typecraft.serializing import TypeSerializer
class MyClass:
val: int
def __init__(self, val: int):
self.val = val
MY_CLASS_VALIDATOR = TypeValidator(int, MyClass, func=lambda obj: MyClass(obj))
MY_CLASS_SERIALIZER = TypeSerializer(MyClass, int, func=lambda obj: obj.val)
type MyClassType = Annotated[MyClass, MY_CLASS_VALIDATOR, MY_CLASS_SERIALIZER]
# validation discovers the inline validator
validated = validate([0, 1, 2], list[MyClassType])
assert all(isinstance(o, MyClass) for o in validated)
# serialization discovers the inline serializer
assert serialize(validated, source_type=list[MyClassType]) == [0, 1, 2]Two lighter-weight validator forms run at the annotation level itself, without matching based on type:
PredicateValidatoraccepts the object if a boolean function returnsTrue, and raises otherwise.PlainValidatorruns an arbitrary function; its return value replaces the object, and exceptions become validation errors.
from typing import Annotated
from typecraft import validate
from typecraft.validating import PlainValidator, PredicateValidator
# predicate
positive = PredicateValidator(lambda x: x > 0)
assert validate([1, 2, 3], list[Annotated[int, positive]]) == [1, 2, 3]
# transformer (mode="before" runs prior to type-based validation)
def parse_int(val: object) -> int:
if isinstance(val, str):
return int(val.strip())
if isinstance(val, int):
return val
raise TypeError(f"cannot parse {type(val).__name__}")
stripped = PlainValidator(parse_int, mode="before")
assert validate([" 1 ", " 2", "3 "], list[Annotated[int, stripped]]) == [1, 2, 3]For common validation tasks, typecraft.lib provides ready-made BaseValidator subclasses:
from typing import Annotated
from typecraft import validate
from typecraft.lib import EmailValidator, IntValidator, StrValidator
# numeric bounds
type PortType = Annotated[int, IntValidator(gt=0, lt=65536)]
assert validate(8080, PortType) == 8080
# string length bounds
type ShortStrType = Annotated[str, StrValidator(min_len=1, max_len=64)]
assert validate("hello", ShortStrType) == "hello"
# email pattern
type EmailType = Annotated[str, EmailValidator()]
assert validate("user@example.com", EmailType) == "user@example.com"Build your own by subclassing BaseValidator[T] and implementing validate().
When validation and serialization are symmetric, BaseSymmetricTypeConverter lets you express both in a single class:
from typecraft.converting.converter.symmetric import BaseSymmetricTypeConverter
from typecraft.serializing import SerializationFrame
from typecraft.validating import ValidationFrame
class RangeConverter(BaseSymmetricTypeConverter[list[int], range]):
"""
`range` <-> `[start, stop, step]` list.
"""
@classmethod
def can_validate(cls, obj: list[int]) -> bool:
return 1 <= len(obj) <= 3
@classmethod
def validate(cls, obj: list[int], frame: ValidationFrame) -> range:
return range(*obj)
@classmethod
def serialize(cls, obj: range, frame: SerializationFrame) -> list[int]:
return [obj.start, obj.stop, obj.step]
# extract the validator/serializer
validator = RangeConverter.as_validator()
serializer = RangeConverter.as_serializer()Type parameters serve as the source/target types for the validator and serializer, so you don't have to repeat them.
For ad-hoc validation and serialization of a specific type, Adapter packages both directions and an optional pair of registries into a single object:
from typecraft.adapter import Adapter
from typecraft.serializing import TypeSerializerRegistry
from typecraft.validating import TypeValidatorRegistry
adapter = Adapter(
range,
validator_registry=TypeValidatorRegistry(RangeConverter.as_validator()),
serializer_registry=TypeSerializerRegistry(RangeConverter.as_serializer()),
)
assert adapter.validate([0, 10]) == range(0, 10)
assert adapter.serialize(range(10)) == [0, 10, 1]BaseModel brings the conversion machinery onto a class. A model is a regular @dataclass(kw_only=True) under the hood — no custom metaclass — with field- and type-level validation, serialization, and aliasing layered on top.
from typecraft import BaseModel, ModelConfig
from typecraft.validating import ValidationParams
class Person(BaseModel):
name: str
age: int = 0
class Team(BaseModel):
# opt into coercion for nested validation
model_config = ModelConfig(default_validation_params=ValidationParams(strict=False))
name: str
members: list[Person]
# nested models can be constructed directly...
team = Team(name="Eng", members=[Person(name="Alice", age=30)])
# ...or from plain dicts, which get validated recursively
team = Team(name="Eng", members=[{"name": "Alice", "age": "30"}])
assert team.members[0].age == 30Validation errors at every level of nesting are aggregated into a single ValidationError with a path to each problem.
model_validate() builds an instance from a mapping, and model_serialize() produces a JSON-compatible dictionary:
data = {"name": "Eng", "members": [{"name": "Alice", "age": 30}]}
team = Team.model_validate(data)
assert team.members[0].name == "Alice"
dump = team.model_serialize()
assert dump == {"name": "Eng", "members": [{"name": "Alice", "age": 30}]}Use @field_validator and @field_serializer to attach custom logic to specific fields. Both decorators support a mode argument: "before" runs prior to type-based conversion, "after" runs once the value is the right type.
from typecraft import BaseModel, field_serializer, field_validator
class Account(BaseModel):
username: str
tags: set[str]
@field_validator("username", mode="before")
@classmethod
def normalize_username(cls, obj: object) -> object:
return obj.strip().lower() if isinstance(obj, str) else obj
@field_validator("username", mode="after")
@classmethod
def check_length(cls, obj: str) -> str:
if not (3 <= len(obj) <= 32):
raise ValueError("username must be 3-32 chars")
return obj
@field_serializer("tags")
def sort_tags(self, obj: set[str]) -> list[str]:
return sorted(obj)
acct = Account(username=" Alice ", tags={"admin", "active"})
assert acct.username == "alice"
assert acct.model_serialize() == {"username": "alice", "tags": ["active", "admin"]}Omit field names to apply the validator/serializer to every field. Validators may take an optional ValidationInfo parameter to access the FieldInfo, the validation frame, and any user-defined context:
from typecraft import BaseModel, field_validator, validate
from typecraft.model.methods import ValidationInfo
class Offset(BaseModel):
value: int
@field_validator
def shift(self, obj: object, info: ValidationInfo) -> object:
if isinstance(obj, int) and info.frame.context is not None:
return obj + info.frame.context
return obj
# context is propagated through validate()
offset = validate({"value": 10}, Offset, context=5)
assert offset.value == 15To attach TypeValidators or TypeSerializers scoped to a model (or a subset of its fields), use @type_validators / @type_serializers:
from typing import Any
from typecraft import BaseModel, type_serializers, type_validators
from typecraft.validating import TypeValidator
from typecraft.serializing import TypeSerializer
class MyInt(int):
pass
class Container(BaseModel):
raw: int
custom: MyInt
@type_validators("custom")
@classmethod
def validators(cls) -> tuple[TypeValidator[Any, Any], ...]:
return (TypeValidator(int, MyInt, func=lambda obj: MyInt(obj)),)
@type_serializers
@classmethod
def serializers(cls) -> tuple[TypeSerializer[Any, Any], ...]:
return (TypeSerializer(MyInt, int, func=lambda obj: int(obj)),)
c = Container(raw=1, custom=2)
assert isinstance(c.custom, MyInt)
assert c.model_serialize() == {"raw": 1, "custom": 2}Pass field names to scope a converter to specific fields, or omit them to apply it to all fields.
Field(alias=...) lets a model use a Pythonic field name internally while reading and writing a different key in serialized form. Pass by_alias=True to opt into the alias for either direction:
from typecraft import BaseModel, Field
from typecraft.validating import ValidationParams
from typecraft.serializing import SerializationParams
class Config(BaseModel):
api_key: str = Field(alias="api-key")
# load using the alias
cfg = Config.model_validate({"api-key": "secret"}, params=ValidationParams(by_alias=True))
assert cfg.api_key == "secret"
# dump using the alias
dump = cfg.model_serialize(params=SerializationParams(by_alias=True))
assert dump == {"api-key": "secret"}By default, validation runs only at construction time. Set validate_on_assignment=True to revalidate on every attribute assignment:
from typecraft import BaseModel, ModelConfig, ValidationError
class Strict(BaseModel):
model_config = ModelConfig(validate_on_assignment=True)
count: int = 0
s = Strict()
s.count = 5
try:
s.count = "5" # type: ignore
except ValidationError:
passBy default, extra fields passed to a model are silently ignored. Set extra="forbid" to raise on them:
from typecraft import BaseModel, ModelConfig, ValidationError
class Strict(BaseModel):
model_config = ModelConfig(extra="forbid")
name: str
try:
Strict.model_validate({"name": "alice", "rogue": True})
except ValidationError as e:
print(e)The typecraft.extras.toml module layers TypeCraft's modeling on top of tomlkit to provide a typed, mutable, round-trippable interface for TOML documents. Field assignments are propagated to the underlying tomlkit tree, so item-level details like array multiline-ness, comments, and key ordering are preserved when the document is dumped.
Subclass BaseDocument for the top-level document and BaseTable / BaseInlineTable for nested tables. Field types may be Python primitives, tomlkit item types, or other wrapper subclasses:
from tomlkit.items import Integer, String
from typecraft import Field
from typecraft.extras.toml import BaseDocument, BaseInlineTable, BaseTable
class ServerTable(BaseTable):
host: String
port: Integer
class CredentialsInline(BaseInlineTable):
user: str
password: str
class Config(BaseDocument):
name: String
server: ServerTable = Field(alias="server")
credentials: CredentialsInline
optional_note: str | None = NoneUse ArrayWrapper[T] for arrays of primitive or inline-table items, and AoTWrapper[T] for arrays of standalone tables:
from typecraft.extras.toml import AoTWrapper, ArrayWrapper, BaseDocument, BaseTable
from tomlkit.items import String
class Endpoint(BaseTable):
path: String
method: String
class API(BaseDocument):
allowed_ports: ArrayWrapper[int]
grid: ArrayWrapper[ArrayWrapper[int]]
endpoints: AoTWrapper[Endpoint]The wrappers behave like ordinary MutableSequences — iterate, index, append, slice-assign, and so on. Mutations propagate to the underlying tomlkit array.
Loading parses with tomlkit and validates the result against your model. Dumping emits the wrapped tomlkit document, so any formatting that came in is preserved on the way out:
from pathlib import Path
config = Config.loads("""\
name = "my-service"
credentials = {user = "admin", password = "hunter2"}
[server]
host = "0.0.0.0"
port = 8080
""")
assert config.server.port == 8080
# mutate freely; the underlying tomlkit document tracks changes
config.server.port = 9090
config.optional_note = "patched"
# dump preserves the original structure plus our edits
print(config.dumps())
# or write straight to a file
config.dump(Path("config.toml"))Setting an Optional field to None removes the corresponding key from the document, and assigning a wrapper instance plugs it into the same tomlkit tree:
new_server = ServerTable(host="127.0.0.1", port=8000)
config.server = new_server
# the new table is now part of the same document
assert config.tomlkit_obj["server"]["port"] == 8000
config.optional_note = None
assert "optional_note" not in config.tomlkit_obj