Strict typed string base class with exact runtime subtype preservation and optional Pydantic v2 support.
Project description
base-typed-string
Strict typed string base class with exact runtime subtype preservation.
base_typed_string is a small Python library for building domain-specific string types that remain real str objects at runtime.
It is designed for codebases where values such as UserName, EmailAddress, AccountKey, or RawInputStr should be:
- strongly named in type annotations
- real
strobjects at runtime - serializable as plain strings
- reconstructable at validation boundaries
- lightweight and predictable
Why
Sometimes a value is semantically important enough to deserve its own type, but operationally it should still behave like a normal Python string.
Examples:
UserNameEmailAddressAccountKeyRawInputStrIntegrationNameValidatedInputStr
Using plain str everywhere loses domain meaning.
Using wrappers changes runtime behavior.
Using NewType helps only static typing.
base_typed_string gives you a middle ground:
domain-specific names in type annotations, while keeping real str behavior at runtime.
What it guarantees
- accepts only
str - preserves the exact subclass type at construction time
- behaves like normal
str - normal string operations return plain
str - preserves subtype through pickle roundtrip
- supports Pydantic v2, but does not require it
- ships
py.typed
What it intentionally does not do
- no built-in validation rules
- no normalization
- no regex engine
- no domain-specific methods
This package is intentionally minimal.
Domain rules should live in your subclasses or in your application layer.
Why not plain str / NewType / custom wrapper?
Why not plain str?
Because plain str does not communicate domain intent.
def create_user(user_name: str, email_address: str) -> None:
...
This is easy to misuse:
- parameters can be swapped accidentally
- type annotations do not explain domain meaning
- static analysis cannot distinguish semantic string types
With typed subclasses:
def create_user(user_name: UserName, email_address: EmailAddress) -> None:
...
the intent is explicit.
Why not typing.NewType?
NewType is a static typing tool, not a runtime type.
from typing import NewType
UserName = NewType("UserName", str)
user_name: UserName = UserName("alice")
assert type(user_name) is str
assert isinstance(user_name, 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
UserNamefrom plainstr
base_typed_string creates a real runtime subtype instead.
Why not a custom wrapper class?
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_string is for the opposite case:
keep the value operationally identical to str, while still having a named domain type.
When base_typed_string is the right choice
Use it when you want:
- semantic string types in annotations
- real
strbehavior at runtime - plain string serialization
- clean interoperability with Python and library code
Do not use it when you need:
- heavy domain logic on the value object
- mutable state
- multiple fields
- non-string runtime representation
Installation
Base package
pip install base-typed-string
With Pydantic v2 support
pip install "base-typed-string[pydantic]"
If Pydantic v2 is already installed in your project, integration works automatically.
For development
pip install "base-typed-string[dev]"
Quick start
from base_typed_string import BaseTypedString
class UserName(BaseTypedString):
pass
user_name: UserName = UserName("alice")
assert user_name == "alice"
assert isinstance(user_name, str)
assert isinstance(user_name, UserName)
assert type(user_name) is UserName
How to use it in your project
Create a module for your domain string types.
For example, create a file named domain_typings.py:
from base_typed_string import BaseTypedString
class UserName(BaseTypedString):
"""User login name."""
class EmailAddress(BaseTypedString):
"""User email address."""
Then use these types in your application code:
from .domain_typings import EmailAddress, UserName
def create_user(user_name: UserName, email_address: EmailAddress) -> None:
print(user_name, email_address)
This gives you:
- domain-specific names in type annotations
- real
strvalues at runtime - plain string serialization behavior
- reconstruction through validation layers such as Pydantic
Runtime behavior
BaseTypedString is a real str subclass.
from base_typed_string import BaseTypedString
class UserName(BaseTypedString):
pass
user_name: UserName = UserName("alice")
assert isinstance(user_name, str)
assert isinstance(user_name, UserName)
assert type(user_name) is UserName
assert user_name == "alice"
Normal string operations return plain str
from base_typed_string import BaseTypedString
class UserName(BaseTypedString):
pass
user_name: UserName = UserName("alice")
uppercased_value: str = user_name.upper()
concatenated_value: str = user_name + "!"
replaced_value: str = user_name.replace("a", "A")
assert type(uppercased_value) is str
assert type(concatenated_value) is str
assert type(replaced_value) is str
This behavior is intentional.
The typed subtype is preserved at construction and validation boundaries, not across ordinary string operations.
Constructor rules
Only str values are accepted.
from base_typed_string import BaseTypedString
class UserName(BaseTypedString):
pass
UserName("alice") # valid
UserName(123) # raises BaseTypedStringInvalidInputValueError
UserName(None) # raises BaseTypedStringInvalidInputValueError
Existing typed string instances are also accepted because they are still real strings:
from base_typed_string import BaseTypedString
class UserName(BaseTypedString):
pass
source_user_name: UserName = UserName("alice")
copied_user_name: UserName = UserName(source_user_name)
assert copied_user_name == "alice"
assert type(copied_user_name) is UserName
Direct instantiation of the base class is also supported:
from base_typed_string import BaseTypedString
plain_typed_value: BaseTypedString = BaseTypedString("value")
assert plain_typed_value == "value"
assert type(plain_typed_value) is BaseTypedString
Pydantic v2 support
When used as a Pydantic field type:
- validation accepts strict strings
- runtime model values preserve the exact subtype
- exported payloads are plain strings
from pydantic import BaseModel
from base_typed_string import BaseTypedString
class EmailAddress(BaseTypedString):
pass
class ContactModel(BaseModel):
primary_email: EmailAddress
backup_email: EmailAddress
contact_model: ContactModel = ContactModel.model_validate(
{
"primary_email": "primary@example.com",
"backup_email": "backup@example.com",
}
)
assert type(contact_model.primary_email) is EmailAddress
assert type(contact_model.backup_email) is EmailAddress
dumped_python: dict[str, object] = contact_model.model_dump()
assert dumped_python == {
"primary_email": "primary@example.com",
"backup_email": "backup@example.com",
}
assert type(dumped_python["primary_email"]) is str
Important boundary
Inside the validated model, the exact subtype is preserved.
After serialization or export, values intentionally become plain strings.
This is a feature, not a bug.
Pickle support
Pickle roundtrip preserves the exact subtype.
import pickle
from base_typed_string import BaseTypedString
class EmailAddress(BaseTypedString):
pass
source_email: EmailAddress = EmailAddress("hello@example.com")
serialized_email: bytes = pickle.dumps(source_email)
restored_email: object = pickle.loads(serialized_email)
assert restored_email == "hello@example.com"
assert type(restored_email) is EmailAddress
JSON behavior
Since BaseTypedString inherits from str, standard JSON serialization naturally produces plain JSON strings.
import json
from base_typed_string import BaseTypedString
class EmailAddress(BaseTypedString):
pass
value: EmailAddress = EmailAddress("hello@example.com")
serialized_value: str = json.dumps(value)
restored_value: object = json.loads(serialized_value)
assert serialized_value == '"hello@example.com"'
assert restored_value == "hello@example.com"
assert type(restored_value) is str
This 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.
Public API
from base_typed_string import BaseTypedString
from base_typed_string import BaseTypedStringError
from base_typed_string import BaseTypedStringInvalidInputValueError
from base_typed_string import BaseTypedStringInvariantViolationError
Exceptions
BaseTypedStringError
Root exception for all package-specific errors.
BaseTypedStringInvalidInputValueError
Raised when a non-string input value is provided.
BaseTypedStringInvariantViolationError
Raised when an internal invariant or contract is violated.
Design notes
BaseTypedString is intended for projects that want domain-specific names without giving up normal str runtime behavior.
This is especially useful when you have many semantic string types such as:
AccountKeyPromptKeyStrRawInputStrIntegrationNameUserTextInputStrValidatedInputStr
The base class stays intentionally small so that your domain layer remains explicit and predictable.
Development
Run tests
pytest
Run lint
ruff check .
Run type checking
mypy .
pyright
Build package
python -m build
Validate distribution metadata
twine check dist/*
Compatibility
- Python 3.10+
- CPython
- optional Pydantic v2 support
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file base_typed_string-0.1.2.tar.gz.
File metadata
- Download URL: base_typed_string-0.1.2.tar.gz
- Upload date:
- Size: 13.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b5219da037619300a679b0f10a9537b7a6d27792fb7f1a7a386e93947237b172
|
|
| MD5 |
d42ef889b69490268ac3575f47fdae38
|
|
| BLAKE2b-256 |
f4958b299c41e741daeabfc2fefd98b1ccfd48b229dea110e7c6930854b92c23
|
File details
Details for the file base_typed_string-0.1.2-py3-none-any.whl.
File metadata
- Download URL: base_typed_string-0.1.2-py3-none-any.whl
- Upload date:
- Size: 7.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fbdc9cc10b584deb198743dad851b09cba59bfe5d9b21935ae7df7607e86c021
|
|
| MD5 |
e13f6b0ca334a2c64b723af54aaf882b
|
|
| BLAKE2b-256 |
89511e7a35203a501935345ec3a8d2300803153ed9bf63443e029f80812dee8c
|