Skip to main content

Annotation-native toolkit for type inspection, validation, and data modeling

Project description

TypeCraft

Annotation-native toolkit for type inspection, validation, and data modeling

Python versions PyPI Tests Coverage Code style: black

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:

  1. A typing layer that wraps an annotation into a rich, introspectable container, with isinstance-like and issubclass-like checks that honor generics, unions, and Literal[].
  2. 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.
  3. A modeling layer (BaseModel) that turns ordinary dataclasses into validated models with field- and type-level converters, aliases, and configurable behavior — without metaclass shenanigans.
  4. A TOML extra that combines the modeling layer with tomlkit to give a typed, mutable, round-trippable interface for TOML documents.

Each layer is usable on its own. You can use Annotation purely as a typing utility, or validate() and serialize() as standalone functions, without ever touching BaseModel or the TOML extra.

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:

  • PredicateValidator accepts the object if a boolean function returns True, and raises otherwise.
  • PlainValidator runs 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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

typecraft-0.2.0.tar.gz (49.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

typecraft-0.2.0-py3-none-any.whl (64.5 kB view details)

Uploaded Python 3

File details

Details for the file typecraft-0.2.0.tar.gz.

File metadata

  • Download URL: typecraft-0.2.0.tar.gz
  • Upload date:
  • Size: 49.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","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}

File hashes

Hashes for typecraft-0.2.0.tar.gz
Algorithm Hash digest
SHA256 c2a4c2c8224e3809d58d2405f21a8d0e9c3986f4c7912abbd15304ba501d64c9
MD5 a0be6ff5a3e6a24262d1eb8edeede9b1
BLAKE2b-256 9d46c27e6407a4df396abadf3d06d4062ceb76fee4961bca77a9e43a8e57f311

See more details on using hashes here.

File details

Details for the file typecraft-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: typecraft-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 64.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","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}

File hashes

Hashes for typecraft-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 89b8e064e40d5d482902ea46a415832d03e9de87aed34d491186c02e247565b6
MD5 61d1bca2571213531b2bf647932b6e77
BLAKE2b-256 92f78787efcbc95747813ee9be81ad0aa084bb3337717aaa0e7adcf2aa5c77c9

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page