TypeCraft
Annotation-native toolkit for type inspection, validation, and data modeling
- TypeCraft
Motivation
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 | ✅ | ⚠️ via validators | ✅ converters | ⚠️ enc_hook/dec_hook |
| Round-trippable TOML (formatting-preserving) | ✅ | ❌ | ❌ | ⚠️ (no formatting preservation) |
| Implementation | pure Python | Rust core | pure Python | C extension |
Getting started
Install using pip:
pip install typecraft
To use the TOML extra:
pip install typecraft[toml]
Inspecting annotations
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.
The Annotation container
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 int
Annotation 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 a
Instance and subtype checks
The 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.
Working with Annotated[] metadata
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.extras
This is the same machinery that the validation and serialization layers use to discover converters declared inline as Annotated[T, ...] extras (see below).
Validation and serialization
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).
validate()
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()
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, ...].
Custom type-based converters
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.0
Converters 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]
Plain and predicate validators
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]
Validator library
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().
Symmetric converters
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.
Adapter: bidirectional convenience
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]
Models
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.
Defining a model
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 == 30
Validation errors at every level of nesting are aggregated into a single ValidationError with a path to each problem.
Loading and dumping
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}]}
Field validators and serializers
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 == 15
Type-based validators and serializers
To 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.
Aliases
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"}
Validate on assignment
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:
pass
Forbidding extra fields
By 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)
TOML extra
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.
Documents and tables
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 = None
Arrays
Use 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.
Round-tripping
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
Metadata
Release files for typecraft 0.4.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| typecraft-0.4.1.tar.gz | 50.4 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| typecraft-0.4.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 115.4 kB
Release files / typecraft-0.4.1.tar.gz
| Download URL | typecraft-0.4.1.tar.gz |
|---|---|
| Size | 50.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8daf18e99c64baff55d12affbeb6cd0999828ad5eb915705aa2b7b312d17bb0a
|
|
BLAKE2b-256 checksum How to use checksums |
27acfd531d556c2d634ce5d56ed0ebc226b499e32ec85117d016aeef45f26ebd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / typecraft-0.4.1-py3-none-any.whl
| Download URL | typecraft-0.4.1-py3-none-any.whl |
|---|---|
| Size | 65.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
098faa7924065ed9ea77c6364295929f5236b8c4e5433a599ba5db79b2b67cbb
|
|
BLAKE2b-256 checksum How to use checksums |
4b2a1ca0000ef821b9850f9ddeef6b598271032eb901d766d9bd5148a6fb524f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.25 {"installer":{"name":"uv","version":"0.11.25","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for typecraft 0.4.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.