base_typed_id is a small Python library for building domain-specific UUID identifier types that remain real str objects at runtime.
It provides two base classes:
BaseTypedIdfor plain UUID-backed typed identifiersBasePrefixedTypedIdfor canonical prefixed identifiers likeuser_<uuid>
Strict typed UUID identifier base classes with exact runtime subtype preservation and optional Pydantic v2 support.
Sometimes an identifier is semantically important enough to deserve its own type, but operationally it should still behave like a normal string.
Examples:
UserIdOrderIdExternalEventIdWorkspaceIdIntegrationId
Sometimes you want plain UUID text:
123e4567-e89b-42d3-a456-426614174000
Sometimes you want a canonical prefixed form:
user_123e4567-e89b-42d3-a456-426614174000workspace_123e4567-e89b-42d3-a456-426614174000
Using plain str everywhere loses domain meaning.
Using plain uuid.UUID changes runtime behavior.
Using wrappers adds interoperability friction.
Using NewType helps only static typing.
base_typed_id gives you a middle ground:
domain-specific UUID-backed identifiers with validation, while keeping real str behavior at runtime.
A transparent domain-typed UUID identifier.
Canonical runtime form:
<uuid>
Example:
from base_typed_id import BaseTypedId
class UserId(BaseTypedId):
passRuntime value:
UserId("123e4567-e89b-42d3-a456-426614174000")A transparent domain-typed UUID identifier with a class-level canonical prefix.
Canonical runtime form:
<prefix>_<uuid>
Example:
from base_typed_id import BasePrefixedTypedId
class UserId(BasePrefixedTypedId):
prefix = "user"Runtime value:
UserId("user_123e4567-e89b-42d3-a456-426614174000")Both base classes:
- accept valid UUID strings and
uuid.UUIDvalues - support auto-generation when called without an explicit value when
uuid_versionis4orNone - validate UUID structure at construction time
- preserve the exact subclass type at construction and validation boundaries
- behave like normal
str - normal string operations return plain
str - preserve subtype through pickle roundtrip
- support Pydantic v2, but do not require it
- serialize and export as plain string
- ship
py.typed
-
canonical runtime form is plain UUID text
-
Pydantic / OpenAPI schema uses:
type: stringformat: uuid
-
canonical runtime form is
<prefix>_<uuid> -
prefix is a class-level invariant
-
prefix must be canonical lowercase snake case
-
regex is derived from the declared prefix and cannot be overridden
-
Pydantic / OpenAPI schema uses:
type: stringpattern: ^<prefix>_<uuid-regex>$
- no built-in domain rules beyond UUID parsing, prefix invariants, and optional version checks
- no normalization layer beyond canonical runtime construction
- no non-UUID identifier support
- no domain-specific methods
- no wrapper objects
- no built-in deterministic factory for prefixed identifiers in the current implementation
This package is intentionally minimal.
Domain rules should live in your subclasses or in your application layer.
Because plain str does not communicate domain intent.
def get_user(user_id: str, workspace_id: str) -> None:
...This is easy to misuse:
- parameters can be swapped accidentally
- type annotations do not explain domain meaning
- static analysis cannot distinguish semantic identifier types
With typed subclasses:
def get_user(user_id: UserId, workspace_id: WorkspaceId) -> None:
...the intent is explicit.
uuid.UUID validates structure, but it is not the same thing as a domain identifier type.
from uuid import UUID
def get_user(user_id: UUID, workspace_id: UUID) -> None:
...This still loses semantic distinction:
user_idandworkspace_idare the same runtime type- static analysis cannot distinguish one UUID-based domain identifier from another
- annotations do not preserve domain meaning
- exported JSON often requires explicit conversion to string
- many integrations expect plain
str, notUUID - exact domain subtype identity such as
type(user_id) is UserIdis not available
base_typed_id keeps UUID validation while preserving domain-specific type identity and plain string interoperability.
NewType is a static typing tool, not a runtime type.
from typing import NewType
UserId = NewType("UserId", str)
user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
assert type(user_id) is str
assert isinstance(user_id, str)This means:
- runtime values are still plain
str - there is no real subclass at runtime
- runtime boundaries cannot preserve a concrete semantic subtype
- introspection and runtime behavior cannot distinguish
UserIdfrom plainstr - UUID validation is not part of construction
base_typed_id creates a real runtime subtype with UUID validation.
Annotated can attach metadata for validators and frameworks, but it still does not create a distinct runtime type.
That means:
- runtime values are still plain
str type(value)is not your domain identifier type- exact subtype identity is not preserved inside Python objects
If you need runtime identity such as type(user_id) is UserId, Annotated[str, ...] is not enough.
A wrapper can model a domain value, but it stops being a real string.
Typical trade-offs:
isinstance(value, str)becomesFalse- JSON serialization often needs custom handling
- many libraries expect plain
str, not wrapper objects - you often need explicit
.valueextraction - interoperability becomes noisier
A wrapper is useful when you want rich behavior and strict encapsulation.
base_typed_id is for the opposite case:
keep the identifier operationally identical to str, while still having a named domain type with UUID guarantees.
Use it when you want:
- semantic identifier types in annotations
- UUID validation at construction and validation boundaries
- real
strbehavior at runtime - plain string serialization
- clean interoperability with Python and library code
- Pydantic / OpenAPI compatibility
Use BaseTypedId when you want plain UUID runtime strings.
Use BasePrefixedTypedId when you want canonical prefixed runtime strings.
Do not use it when you need:
- heavy domain logic on the identifier itself
- mutable state
- multiple fields
- non-UUID runtime representation
- non-UUID identifiers
pip install base-typed-idpip install "base-typed-id[pydantic]"pip install "base-typed-id[dev]"from base_typed_id import BaseTypedId
class UserId(BaseTypedId):
pass
user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
generated_user_id: UserId = UserId()
assert user_id == "123e4567-e89b-42d3-a456-426614174000"
assert isinstance(user_id, str)
assert isinstance(user_id, UserId)
assert type(user_id) is UserId
assert type(generated_user_id) is UserIdfrom base_typed_id import BasePrefixedTypedId
class UserId(BasePrefixedTypedId):
prefix = "user"
user_id_from_plain_uuid: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
user_id_from_prefixed_string: UserId = UserId(
"user_123e4567-e89b-42d3-a456-426614174000"
)
generated_user_id: UserId = UserId()
assert user_id_from_plain_uuid == "user_123e4567-e89b-42d3-a456-426614174000"
assert user_id_from_prefixed_string == "user_123e4567-e89b-42d3-a456-426614174000"
assert isinstance(user_id_from_plain_uuid, str)
assert isinstance(user_id_from_plain_uuid, UserId)
assert type(user_id_from_plain_uuid) is UserId
assert type(generated_user_id) is UserIdCreate a module for your domain identifier types.
For example, create a file named domain_identifiers.py:
from base_typed_id import BasePrefixedTypedId, BaseTypedId
class UserId(BasePrefixedTypedId):
"""User identifier."""
prefix = "user"
class WorkspaceId(BasePrefixedTypedId):
"""Workspace identifier."""
prefix = "workspace"
class ExternalEventId(BaseTypedId):
"""External event identifier."""
uuid_version = 5Then use these types in your application code:
from .domain_identifiers import ExternalEventId, UserId, WorkspaceId
def get_user(
user_id: UserId,
workspace_id: WorkspaceId,
external_event_id: ExternalEventId,
) -> None:
print(user_id, workspace_id, external_event_id)This gives you:
- domain-specific names in type annotations
- UUID validation at boundaries
- real
strvalues at runtime - plain string serialization behavior
- reconstruction through validation layers such as Pydantic
Both base classes are real str subclasses backed by UUID validation.
from base_typed_id import BaseTypedId
class UserId(BaseTypedId):
pass
user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
assert isinstance(user_id, str)
assert isinstance(user_id, UserId)
assert type(user_id) is UserId
assert user_id == "123e4567-e89b-42d3-a456-426614174000"from base_typed_id import BasePrefixedTypedId
class UserId(BasePrefixedTypedId):
prefix = "user"
user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
assert isinstance(user_id, str)
assert isinstance(user_id, UserId)
assert type(user_id) is UserId
assert user_id == "user_123e4567-e89b-42d3-a456-426614174000"from base_typed_id import BasePrefixedTypedId
class UserId(BasePrefixedTypedId):
prefix = "user"
user_id: UserId = UserId("user_123e4567-e89b-42d3-a456-426614174000")
uppercased_value: str = user_id.upper()
concatenated_value: str = user_id + "_debug"
replaced_value: str = user_id.replace("-", "_")
assert type(uppercased_value) is str
assert type(concatenated_value) is str
assert type(replaced_value) is strThis behavior is intentional.
The typed subtype is preserved at construction and validation boundaries, not across ordinary string operations.
Valid inputs are:
- no argument
- UUID string
uuid.UUID
Calling the constructor without an argument auto-generates a UUID when uuid_version is 4 or None.
from uuid import UUID
from base_typed_id import BaseTypedId
class UserId(BaseTypedId):
pass
value_from_string: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
value_from_uuid: UserId = UserId(UUID("123e4567-e89b-42d3-a456-426614174000"))
generated_value: UserId = UserId()Valid inputs are:
- no argument
- canonical prefixed UUID string
- raw UUID string
uuid.UUID
Calling the constructor without an argument auto-generates a UUID when uuid_version is 4 or None.
from uuid import UUID
from base_typed_id import BasePrefixedTypedId
class UserId(BasePrefixedTypedId):
prefix = "user"
value_from_prefixed_string: UserId = UserId(
"user_123e4567-e89b-42d3-a456-426614174000"
)
value_from_plain_uuid_string: UserId = UserId(
"123e4567-e89b-42d3-a456-426614174000"
)
value_from_uuid: UserId = UserId(UUID("123e4567-e89b-42d3-a456-426614174000"))
generated_value: UserId = UserId()
assert value_from_prefixed_string == "user_123e4567-e89b-42d3-a456-426614174000"
assert value_from_plain_uuid_string == "user_123e4567-e89b-42d3-a456-426614174000"
assert value_from_uuid == "user_123e4567-e89b-42d3-a456-426614174000"Invalid input raises BaseTypedIdInvalidInputValueError.
from base_typed_id import (
BasePrefixedTypedId,
BaseTypedId,
BaseTypedIdInvalidInputValueError,
)
class UserId(BaseTypedId):
pass
class PrefixedUserId(BasePrefixedTypedId):
prefix = "user"
try:
UserId("not-a-uuid")
except BaseTypedIdInvalidInputValueError:
pass
try:
PrefixedUserId("user_not-a-uuid")
except BaseTypedIdInvalidInputValueError:
pass
try:
PrefixedUserId(123)
except BaseTypedIdInvalidInputValueError:
passEach concrete subclass must declare its own class-level prefix.
from base_typed_id import BasePrefixedTypedId
class UserId(BasePrefixedTypedId):
prefix = "user"Rules:
prefixmust be declared directly on the subclassprefixmust be astrprefixmust be canonical lowercase snake caseregexis derived fromprefixregexcannot be overridden
Examples of invalid subclasses:
from base_typed_id import BasePrefixedTypedId
class MissingPrefix(BasePrefixedTypedId):
passfrom base_typed_id import BasePrefixedTypedId
class InvalidPrefixType(BasePrefixedTypedId):
prefix = 123from base_typed_id import BasePrefixedTypedId
class InvalidPrefixFormat(BasePrefixedTypedId):
prefix = "User"import re
from base_typed_id import BasePrefixedTypedId
class InvalidRegexOverride(BasePrefixedTypedId):
prefix = "user"
regex = re.compile("^custom$")These raise BaseTypedIdInvariantViolationError.
By default, both base classes enforce UUID v4.
from base_typed_id import BasePrefixedTypedId, BaseTypedId
class UserId(BaseTypedId):
pass
class WorkspaceId(BasePrefixedTypedId):
prefix = "workspace"Use another version explicitly:
from base_typed_id import BasePrefixedTypedId, BaseTypedId
class ExternalEventId(BaseTypedId):
uuid_version = 5
class ImportedEntityId(BasePrefixedTypedId):
prefix = "imported_entity"
uuid_version = 5Disable version restriction:
from base_typed_id import BasePrefixedTypedId, BaseTypedId
class FlexibleId(BaseTypedId):
uuid_version = None
class FlexiblePrefixedId(BasePrefixedTypedId):
prefix = "flexible"
uuid_version = NoneWhen uuid_version is not 4 or None, auto-generation from None is intentionally rejected.
For idempotent identifiers, the package provides deterministically_from_words.
This factory currently works with BaseTypedId subclasses.
from base_typed_id import BaseTypedId, deterministically_from_words
class ExternalEventId(BaseTypedId):
uuid_version = 5
event_id: ExternalEventId = deterministically_from_words(
ExternalEventId,
words=[
"workspace:house-of-ai",
"provider:telegram",
"event:message-created",
"message:42",
],
)Properties:
- same words -> same identifier
- order matters
- deterministic generation requires
uuid_version = 5oruuid_version = None
When used as a Pydantic field type:
- validation accepts UUID objects and UUID strings
- runtime model values preserve the exact subtype
- exported payloads are plain strings
- generated schema keeps
type: stringandformat: uuid
from pydantic import BaseModel
from base_typed_id import BaseTypedId
class UserId(BaseTypedId):
pass
class UserModel(BaseModel):
user_id: UserId
user_model: UserModel = UserModel.model_validate(
{
"user_id": "123e4567-e89b-42d3-a456-426614174000",
}
)
assert type(user_model.user_id) is UserId
dumped_python: dict[str, object] = user_model.model_dump()
assert dumped_python == {
"user_id": "123e4567-e89b-42d3-a456-426614174000",
}
assert type(dumped_python["user_id"]) is strWhen used as a Pydantic field type:
-
Python-side validation accepts:
- UUID objects
- raw UUID strings
- canonical prefixed strings
-
runtime model values preserve the exact subtype
-
exported payloads are plain strings
-
generated schema keeps
type: stringand a prefix-derivedpattern
from pydantic import BaseModel
from base_typed_id import BasePrefixedTypedId
class UserId(BasePrefixedTypedId):
prefix = "user"
class UserModel(BaseModel):
user_id: UserId
user_model: UserModel = UserModel.model_validate(
{
"user_id": "123e4567-e89b-42d3-a456-426614174000",
}
)
assert type(user_model.user_id) is UserId
assert user_model.user_id == "user_123e4567-e89b-42d3-a456-426614174000"
dumped_python: dict[str, object] = user_model.model_dump()
assert dumped_python == {
"user_id": "user_123e4567-e89b-42d3-a456-426614174000",
}
assert type(dumped_python["user_id"]) is strInside the validated model, the exact subtype is preserved.
After serialization or export, values intentionally become plain strings.
For JSON input validation through Pydantic, the current implementation accepts canonical prefixed strings and rejects raw UUID strings.
from pydantic import BaseModel, ValidationError
from base_typed_id import BasePrefixedTypedId
class UserId(BasePrefixedTypedId):
prefix = "user"
class UserModel(BaseModel):
user_id: UserId
UserModel.model_validate_json(
'{"user_id":"user_123e4567-e89b-42d3-a456-426614174000"}'
)
try:
UserModel.model_validate_json(
'{"user_id":"123e4567-e89b-42d3-a456-426614174000"}'
)
except ValidationError:
passThis is a feature of the current schema design, not an accidental test artifact.
Pickle roundtrip preserves the exact subtype for both base classes.
import pickle
from base_typed_id import BaseTypedId
class UserId(BaseTypedId):
pass
source_user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
serialized_user_id: bytes = pickle.dumps(source_user_id)
restored_user_id: object = pickle.loads(serialized_user_id)
assert restored_user_id == "123e4567-e89b-42d3-a456-426614174000"
assert type(restored_user_id) is UserIdimport pickle
from base_typed_id import BasePrefixedTypedId
class UserId(BasePrefixedTypedId):
prefix = "user"
source_user_id: UserId = UserId("user_123e4567-e89b-42d3-a456-426614174000")
serialized_user_id: bytes = pickle.dumps(source_user_id)
restored_user_id: object = pickle.loads(serialized_user_id)
assert restored_user_id == "user_123e4567-e89b-42d3-a456-426614174000"
assert type(restored_user_id) is UserIdThe pickle protocol internally uses a tuple shaped like:
tuple[type[UserId], tuple[str]]Static type checkers preserve the exact subclass through that tuple.
from base_typed_id import BaseTypedId
class UserId(BaseTypedId):
pass
user_id: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
reduce_tuple: tuple[type[UserId], tuple[str]] = user_id.__reduce__()
rebuilt_user_id: UserId = reduce_tuple[0](*reduce_tuple[1])
assert type(rebuilt_user_id) is UserIdThis is covered by static tests for both mypy and pyright.
Since both base classes inherit from str, standard JSON serialization naturally produces plain JSON strings.
import json
from base_typed_id import BaseTypedId
class UserId(BaseTypedId):
pass
value: UserId = UserId("123e4567-e89b-42d3-a456-426614174000")
serialized_value: str = json.dumps(value)
restored_value: object = json.loads(serialized_value)
assert serialized_value == '"123e4567-e89b-42d3-a456-426614174000"'
assert restored_value == "123e4567-e89b-42d3-a456-426614174000"
assert type(restored_value) is strimport json
from base_typed_id import BasePrefixedTypedId
class UserId(BasePrefixedTypedId):
prefix = "user"
value: UserId = UserId("user_123e4567-e89b-42d3-a456-426614174000")
serialized_value: str = json.dumps(value)
restored_value: object = json.loads(serialized_value)
assert serialized_value == '"user_123e4567-e89b-42d3-a456-426614174000"'
assert restored_value == "user_123e4567-e89b-42d3-a456-426614174000"
assert type(restored_value) is strThis behavior is intentional.
JSON is a plain data boundary.
The exact runtime subtype exists only inside Python objects. After serialization, values become plain strings and do not carry subtype information.
from base_typed_id import BasePrefixedTypedId
from base_typed_id import BaseTypedId
from base_typed_id import BaseTypedIdError
from base_typed_id import BaseTypedIdInvalidInputValueError
from base_typed_id import BaseTypedIdInvariantViolationError
from base_typed_id import deterministically_from_wordsRoot exception for all package-specific errors.
Raised when an invalid UUID input value is provided.
Raised when an internal invariant or contract is violated.
Current examples:
examples/basic_usage.pyexamples/deterministic_factory_usage.pyexamples/pickle_roundtrip.pyexamples/pydantic_roundtrip_from_dump.pyexamples/pydantic_runtime_vs_dump.pyexamples/static_tuple_type_preservation.pyexamples/prefixed_basic_usage.pyexamples/prefixed_pydantic_runtime_vs_dump.py
BaseTypedId is intended for projects that want domain-specific UUID identifier names without giving up normal str runtime behavior.
BasePrefixedTypedId is intended for projects that want the same runtime behavior, but with canonical prefixed string values such as:
user_<uuid>workspace_<uuid>integration_<uuid>
This is especially useful when you have many semantic identifiers such as:
UserIdWorkspaceIdOrderIdExternalEventIdIntegrationId
The base classes stay intentionally small so that your domain layer remains explicit and predictable.
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"python -m ruff format --check .
python -m ruff check .python -m mypy src tests examples
python -m pyrightpython -m pytestpython examples/static_tuple_type_preservation.py
python -m mypy examples/static_tuple_type_preservation.py
python -m pyright examples/static_tuple_type_preservation.pypython -m buildpython -m twine check dist/*- Python 3.10+
- CPython
- optional Pydantic v2 support
MIT