Skip to main content

base-typed-int

base_typed_int is a small Python library for building domain-specific integer types that remain real int objects at runtime.

Callable branded and constrained integer base classes with exact runtime subtype preservation and optional Pydantic v2 support.

Why

BaseTypedInt helps model domain integers as explicit runtime types without losing normal int behavior.

Example use cases:

  • UserAge
  • RetryCount
  • OrderPosition
  • PriorityLevel
  • ShardNumber

This library is designed for cases where plain int is too generic, but a full wrapper object would add unnecessary ceremony and runtime overhead.

Design goals

  • exact runtime subtype is preserved
  • behaves like plain int in normal arithmetic operations
  • arithmetic operations usually return plain int
  • subtype is preserved in containers, attributes, pickle, and Pydantic model fields
  • Pydantic serialization exports plain integers
  • strict input validation
  • bool is explicitly rejected
  • no extra public API beyond the integer value itself
  • optional intrinsic gt, ge, lt, le, and multiple_of constraints
  • declared constraints appear in Pydantic-generated JSON Schema

Installation

Core

pip install base-typed-int

With Pydantic v2 support

pip install "base-typed-int[pydantic]"

Development

pip install "base-typed-int[dev]"

Quick start

from base_typed_int import BaseTypedInt

class UserAge(BaseTypedInt):
    pass

class Account:
    def __init__(self, user_age: UserAge) -> None:
        self.user_age: UserAge = user_age


user_age: UserAge = UserAge(18)

assert user_age == 18
assert isinstance(user_age, int)
assert isinstance(user_age, UserAge)
assert type(user_age) is UserAge
assert repr(user_age) == "UserAge(18)"

account: Account = Account(user_age=user_age)
assert account.user_age is user_age

Constrained typed integers

Use BaseConstrainedTypedInt when numeric rules belong to the named domain type itself:

from base_typed_int import BaseConstrainedTypedInt


class RequestExecutionLeaseTtlMilliseconds(BaseConstrainedTypedInt):
    ge = 1_000
    le = 3_600_000


lease_ttl = RequestExecutionLeaseTtlMilliseconds(30_000)

assert type(lease_ttl) is RequestExecutionLeaseTtlMilliseconds
assert isinstance(lease_ttl, int)

The public symbol is a real, callable class. Construction validates the value even when Pydantic is not installed:

from base_typed_int import BaseTypedIntConstraintViolationError


try:
    RequestExecutionLeaseTtlMilliseconds(500)
except BaseTypedIntConstraintViolationError:
    pass

Constraint properties

Property Meaning
gt Value must be strictly greater than this integer
ge Value must be greater than or equal to this integer
lt Value must be strictly less than this integer
le Value must be less than or equal to this integer
multiple_of Value must be divisible by this positive integer

Every property defaults to None. Bounds may be negative or zero, while multiple_of must be greater than zero. Invalid declarations and constraint sets that permit no integer fail when the class is defined.

Constraint declarations are sealed when the class is created because Pydantic caches schemas. Declare them in the class body. Changing or deleting an effective value later is rejected the next time the class is constructed, validated, serialized, or used to build a schema. Detection on use avoids imposing a custom metaclass, so constrained types remain compatible with ABC and independently metaclassed bases.

A constrained subtype may only narrow its inherited domain: lower bounds may rise, upper bounds may fall, and multiple_of may become a multiple of the inherited value. A class may inherit from only one constrained integer type. Multiple constrained parents are rejected because composing their constraints would silently assign several domain meanings to a different value type.

Declare a combined invariant as an independent type instead:

class EvenInt(BaseConstrainedTypedInt):
    multiple_of = 2


class DivisibleByThreeInt(BaseConstrainedTypedInt):
    multiple_of = 3


# Rejected: this gives one value type two domain parents.
class DivisibleBySixInt(EvenInt, DivisibleByThreeInt):
    pass


# Declare the new value type and its own invariant explicitly.
class DivisibleBySixInt(BaseConstrainedTypedInt):
    multiple_of = 6

Canonical declaration for coding agents

When constraints are invariants of a named domain integer, declare one public class:

from base_typed_int import BaseConstrainedTypedInt


class RequestExecutionAttemptEpoch(BaseConstrainedTypedInt):
    ge = 1

Do not create a public Annotated type alias for the same concept:

from typing import Annotated

from pydantic import Field

from base_typed_int import BaseTypedInt


class RequestExecutionAttemptEpoch(BaseTypedInt):
    pass


# Avoid this for invariants of a named domain type (Python 3.12+).
type PositiveRequestExecutionAttemptEpoch = Annotated[
    RequestExecutionAttemptEpoch,
    Field(ge=1),
]

A PEP 695 type alias is not a class: it is not the reusable constructor or runtime identity of the domain concept. Put every reusable invariant on the BaseConstrainedTypedInt subclass. Annotated[int, ...] remains appropriate for a local field constraint that does not define a reusable branded type.

The constrained class is both annotation and constructor:

def start_attempt(value: RequestExecutionAttemptEpoch) -> None:
    ...


start_attempt(RequestExecutionAttemptEpoch(1))  # accepted
start_attempt(1)  # static type error

Constructor behavior

The constructor is typed as accepting int, while still keeping a runtime validation guard for invalid non-integer inputs crossing dynamic boundaries.

from base_typed_int import BaseTypedInt

class RetryCount(BaseTypedInt):
    pass


value_from_int: RetryCount = RetryCount(3)
value_from_typed_int: RetryCount = RetryCount(RetryCount(3))

Invalid input raises BaseTypedIntInvalidInputValueError.

from base_typed_int import (
    BaseTypedInt,
    BaseTypedIntInvalidInputValueError,
)

class RetryCount(BaseTypedInt):
    pass

try:
    RetryCount("3")
except BaseTypedIntInvalidInputValueError:
    pass

try:
    RetryCount(True)
except BaseTypedIntInvalidInputValueError:
    pass

Why bool is rejected

In Python, bool is a subclass of int.

assert isinstance(True, int)
assert int(True) == 1

That behavior is useful in Python internals, but usually unsafe for domain modeling. A domain integer such as RetryCount, UserAge, or ShardNumber should not silently accept True or False.

For that reason, BaseTypedInt explicitly rejects bool even though bool is technically an int subtype.

Runtime behavior

Normal arithmetic keeps standard Python int semantics.

from base_typed_int import BaseTypedInt

class UserAge(BaseTypedInt):
    pass


user_age: UserAge = UserAge(18)

incremented_value: int = user_age + 1
multiplied_value: int = user_age * 2
subtracted_value: int = user_age - 3

assert incremented_value == 19
assert multiplied_value == 36
assert subtracted_value == 15

assert type(incremented_value) is int
assert type(multiplied_value) is int
assert type(subtracted_value) is int

This is intentional. The typed subtype marks the boundary value itself, while regular numeric operations stay native and unsurprising.

Containers and attributes

The exact subtype instance is preserved when stored and retrieved.

from base_typed_int import BaseTypedInt

class UserAge(BaseTypedInt):
    pass


class Account:
    def __init__(self, user_age: UserAge) -> None:
        self.user_age: UserAge = user_age


source_user_age: UserAge = UserAge(18)

user_age_list: list[UserAge] = [source_user_age]
user_age_by_field_name: dict[str, UserAge] = {
    "user_age": source_user_age,
}
values_by_user_age: dict[int, str] = {
    source_user_age: "present",
}
account: Account = Account(user_age=source_user_age)

assert user_age_list[0] is source_user_age
assert user_age_by_field_name["user_age"] is source_user_age
assert account.user_age is source_user_age
assert values_by_user_age[source_user_age] == "present"
assert values_by_user_age[18] == "present"
assert type(tuple(values_by_user_age.keys())[0]) is UserAge

Pickle support

Pickle roundtrip preserves the exact subtype.

import pickle

from base_typed_int import BaseTypedInt

class RetryCount(BaseTypedInt):
    pass


source_value: RetryCount = RetryCount(7)
serialized_value: bytes = pickle.dumps(source_value)
restored_value: object = pickle.loads(serialized_value)

assert restored_value == 7
assert isinstance(restored_value, RetryCount)
assert type(restored_value) is RetryCount

JSON behavior

Since BaseTypedInt inherits from int, standard JSON serialization naturally produces plain JSON numbers.

import json

from base_typed_int import BaseTypedInt

class RetryCount(BaseTypedInt):
    pass


value: RetryCount = RetryCount(7)
serialized_value: str = json.dumps(value)
restored_value: object = json.loads(serialized_value)

assert serialized_value == "7"
assert restored_value == 7
assert type(restored_value) is int

Pydantic v2 support

Both base classes integrate with Pydantic v2 through __get_pydantic_core_schema__.

Validation rules:

  • accepts only strict integer input
  • rejects bool
  • rejects strings and other non-integer values
  • reconstructs the exact runtime subtype
  • applies intrinsic constrained-class rules without Annotated or Field

Serialization rules:

  • model_dump() returns plain integers
  • model_dump_json() returns JSON numbers

Example

from pydantic import BaseModel

from base_typed_int import BaseTypedInt

class RetryCount(BaseTypedInt):
    pass


class MetricsModel(BaseModel):
    primary_retry_count: RetryCount
    backup_retry_count: RetryCount


metrics_model: MetricsModel = MetricsModel.model_validate(
    {
        "primary_retry_count": 5,
        "backup_retry_count": 8,
    }
)

assert type(metrics_model.primary_retry_count) is RetryCount
assert type(metrics_model.backup_retry_count) is RetryCount

python_dump: dict[str, object] = metrics_model.model_dump()
json_dump: str = metrics_model.model_dump_json()

assert python_dump == {
    "primary_retry_count": 5,
    "backup_retry_count": 8,
}
assert json_dump == '{"primary_retry_count":5,"backup_retry_count":8}'

Roundtrip from exported payload

source_model: MetricsModel = MetricsModel.model_validate(
    {
        "primary_retry_count": 5,
        "backup_retry_count": 8,
    }
)

python_dump: dict[str, object] = source_model.model_dump()
restored_model: MetricsModel = MetricsModel.model_validate(python_dump)

assert type(restored_model.primary_retry_count) is RetryCount
assert type(restored_model.backup_retry_count) is RetryCount

Constrained Pydantic fields

Constrained classes are used directly as field annotations:

from pydantic import BaseModel


class LeaseModel(BaseModel):
    lease_ttl: RequestExecutionLeaseTtlMilliseconds


lease_model = LeaseModel.model_validate({"lease_ttl": 30_000})

assert type(lease_model.lease_ttl) is RequestExecutionLeaseTtlMilliseconds
assert LeaseModel.model_json_schema()["properties"]["lease_ttl"]["minimum"] == 1_000
assert LeaseModel.model_json_schema()["properties"]["lease_ttl"]["maximum"] == 3_600_000

Error types

from base_typed_int import (
    BaseConstrainedTypedInt,
    BaseTypedIntConstraintConfigurationError,
    BaseTypedIntConstraintViolationError,
    BaseTypedIntError,
    BaseTypedIntInvalidInputValueError,
    BaseTypedIntInvariantViolationError,
)
  • BaseTypedIntError — root exception for library errors
  • BaseTypedIntConstraintConfigurationError — invalid or changed constraint declaration
  • BaseTypedIntConstraintViolationError — direct input violates a declared constraint
  • BaseTypedIntInvalidInputValueError — invalid caller input
  • BaseTypedIntInvariantViolationError — internal invariant or integration failure

Testing

pytest

With coverage:

pytest --cov=base_typed_int --cov-report=term-missing

Type checking

mypy src tests
pyright

Linting

ruff check .
ruff format .

Build

python -m build

Package structure

src/
  base_typed_int/
    __init__.py
    _base_constrained_typed_int/
      __init__.py
      _base.py
      _constraints.py
    _base_typed_int.py
    _exceptions.py
    _pydantic_support.py
    py.typed

Compatibility

  • Python 3.10+
  • CPython
  • Pydantic v2 support is optional

Notes

BaseTypedInt remains intentionally unconstrained. BaseConstrainedTypedInt adds only deterministic integer comparisons and divisibility checks while preserving the same callable class, runtime subtype, serialization, and static typing model. More complex domain rules belong in the application layer.

License

MIT

Metadata

Release files for base-typed-int 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for base-typed-int 0.2.0
File Size Uploaded
base_typed_int-0.2.0.tar.gz 24.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for base-typed-int 0.2.0
File Interpreter ABI Platform
base_typed_int-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 37.9 kB

Release files / base_typed_int-0.2.0.tar.gz

Download URL base_typed_int-0.2.0.tar.gz
Size 24.4 kB
Tags Source
SHA-256 checksum
How to use checksums
a7752cad06a07c9c4b98cb228376aa4f61942663ad788d5ed20f92358233c5b6
BLAKE2b-256 checksum
How to use checksums
ec2465d7d2626dfec7d90c1d2b41fc689ac572bb16711e25840230bc337c4ba5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","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":true}

Release files / base_typed_int-0.2.0-py3-none-any.whl

Download URL base_typed_int-0.2.0-py3-none-any.whl
Size 13.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5068a3422a866de2d1e5243b14ddf2764b1aa376539aafbcac240af6c6121983
BLAKE2b-256 checksum
How to use checksums
5d45e81dea7352177d8fbef02ff264b9ecc7ac237515a2a539108631fe85a67e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.11.31 {"installer":{"name":"uv","version":"0.11.31","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":true}

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page