Many systems use the same runtime representation for different concepts. A user ID and an order ID may both be integers; an email address and a country code may both be strings. Passing one in place of the other is still a domain error. typing.NewType creates distinct static types on top of an existing representation without requiring a full wrapper class for every concept.
This guide covers semantic IDs, validated construction, databases, JSON, dataclasses, APIs, aliases, subclasses, value objects, and the runtime limits of NewType.
The primitive-type problem
def load_user(user_id: int) -> str:
...
def load_order(order_id: int) -> str:
...
user_id = 10
order_id = 20
load_user(order_id) # accepted staticallyBoth parameters are integers, so the checker cannot distinguish their meanings.
Creating semantic types
from typing import NewType
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def load_user(user_id: UserId) -> str:
...
def load_order(order_id: OrderId) -> str:
...Passing OrderId where UserId is expected is now a static error.
user_id = UserId(10)
order_id = OrderId(20)
load_user(user_id)
load_user(order_id) # type errorRuntime behavior
NewType does not create a traditional data wrapper. Calling UserId(10) returns the underlying integer at runtime.
value = UserId(10)
print(value) # 10
print(type(value)) # intThe distinction mainly exists for static analysis. This keeps interoperability and overhead simple, but NewType does not enforce validation by itself.
NewType is not a runtime class check
isinstance(value, UserId) # not appropriateThe NewType object is not a domain class to use with isinstance() or issubclass(). Check the base type or use a real value class when runtime identity matters.
NewType versus an alias
UserId = intThis is only an alias. UserId and int remain identical to the checker. With NewType, UserId is distinct from an arbitrary int when passed into a function that requires it.
Relationship with the base type
A NewType value can be used where the base type is accepted.
def double(value: int) -> int:
return value * 2
user_id = UserId(10)
result = double(user_id)UserId acts as a static subtype of int. The reverse direction is not automatic: a regular int is not a UserId until explicitly constructed.
Construction is not validation
user_id = UserId(-10)NewType does not check positivity. The call simply marks the value. Put invariants in a factory or parser.
def create_user_id(value: int) -> UserId:
if value <= 0:
raise ValueError("ID must be positive")
return UserId(value)Centralized construction prevents arbitrary marking throughout the codebase.
Parsing strings
def parse_user_id(text: str) -> UserId:
try:
value = int(text)
except ValueError as error:
raise ValueError("invalid user ID") from error
return create_user_id(value)The parser converts and validates external data before returning the semantic type.
IDs in dataclasses
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
id: UserId
name: str
@dataclass(frozen=True)
class Order:
id: OrderId
user_id: UserIdFields document the domain and prevent accidental associations during construction and refactoring.
Typed repositories
class UserRepository:
def get(self, id_value: UserId) -> User | None:
...
def delete(self, id_value: UserId) -> None:
...The contract clearly identifies which kind of ID the repository accepts.
Database boundaries
Database drivers return base types. The persistence layer should classify them explicitly.
def user_from_row(row: tuple[int, str]) -> User:
raw_id, name = row
return User(id=UserId(raw_id), name=name)If the database does not guarantee the invariant, use the validated factory instead.
JSON and APIs
Serialization generally exposes the base representation.
def user_to_json(user: User) -> dict[str, object]:
return {
"id": int(user.id),
"name": user.name,
}The conversion may be optional at runtime, but making it explicit documents the boundary.
String NewTypes
Email = NewType("Email", str)
CountryCode = NewType("CountryCode", str)
def send(email: Email, message: str) -> None:
...A factory can normalize and validate before returning Email.
def create_email(value: str) -> Email:
normalized = value.strip().casefold()
if "@" not in normalized:
raise ValueError("invalid email")
return Email(normalized)Financial and unit values
NewType can distinguish cents, points, and quantities, but it does not add rounding, currency rules, or safe arithmetic.
Cents = NewType("Cents", int)
Points = NewType("Points", int)For money with complex rules, a value class may be better. NewType works well when the base operations are already sufficient and the main goal is preventing accidental mixing.
Collections
users: list[UserId] = [UserId(1), UserId(2)]
orders: list[OrderId] = [OrderId(10)]The checker keeps semantically different collections separate even though both contain integers at runtime.
Dictionaries and mappings
names: dict[UserId, str] = {
UserId(1): "Ana",
}
orders_by_user: dict[UserId, list[OrderId]] = {}Key and value annotations make domain relationships visible.
Function return types
def create_user(name: str) -> UserId:
raw_id = insert_into_database(name)
return UserId(raw_id)Callers receive an already classified identifier and do not need to guess what the integer means.
Optional values
def find_user(email: Email) -> UserId | None:
...After None is handled, the remaining value is still UserId.
Nested NewTypes
A NewType can use another NewType as its base, but excessive layers may confuse consumers.
Id = NewType("Id", int)
UserId = NewType("UserId", Id)Use this hierarchy only when the subtype relationship provides real value. Direct int or str bases are usually simpler.
NewType versus an int subclass
class RuntimeUserId(int):
passA real subclass exists at runtime and supports isinstance(). Construction, serialization, and operator results may require additional care. NewType is lighter when the distinction is only static.
NewType versus a value dataclass
@dataclass(frozen=True)
class UserIdValue:
value: int
def __post_init__(self) -> None:
if self.value <= 0:
raise ValueError("invalid ID")The dataclass enforces invariants, has runtime identity, and can provide methods. It also requires explicit unwrapping and conversions. Choose based on behavior and validation needs.
NewType versus Annotated
Annotated attaches metadata but normally does not make two annotations distinct to the checker.
from typing import Annotated
DocumentedUserId = Annotated[int, "user"]Use NewType for static distinction and Annotated for metadata consumed by frameworks, validators, or documentation.
Public library design
Exporting NewTypes improves contracts but may be a breaking change for users who passed primitives directly. Plan migrations, offer factories, and document which boundaries require explicit construction.
Arithmetic results
user_id = UserId(10)
next_value = user_id + 1The result of a base-type operation is usually int, not UserId. That is often correct: adding to an identifier does not automatically produce another valid identifier.
Restrict arbitrary marking
def unsafe(value: int) -> UserId:
return UserId(value)If any layer can mark arbitrary integers without validation, the protection loses meaning. Keep construction in trusted parsers, repositories, and factories.
Common mistakes
- Using an alias instead: no static distinction is created.
- Expecting automatic validation: NewType returns the base value.
- Using isinstance with NewType: it is not a runtime domain class.
- Marking external data without checks: the annotation does not prove an invariant.
- Expecting operations to preserve the NewType: results often return to the base type.
- Creating too many semantic types: focus on important, plausible mix-ups.
Complete transfer-service example
from dataclasses import dataclass
from typing import NewType
AccountId = NewType("AccountId", int)
Cents = NewType("Cents", int)
@dataclass(frozen=True)
class Transfer:
source: AccountId
destination: AccountId
amount: Cents
def create_account_id(value: int) -> AccountId:
if value <= 0:
raise ValueError("invalid account")
return AccountId(value)
def create_cents(value: int) -> Cents:
if value <= 0:
raise ValueError("amount must be positive")
return Cents(value)
def transfer(
source: AccountId,
destination: AccountId,
amount: Cents,
) -> Transfer:
if source == destination:
raise ValueError("accounts must differ")
record_transfer(source, destination, amount)
return Transfer(source, destination, amount)The checker prevents Cents from being passed as AccountId. Factories enforce runtime rules.
Testing typing
Include static tests with expected invalid calls and use reveal_type() to inspect operation results. Runtime tests should focus on factories because that is where invariants are actually enforced.
Conclusion
typing.NewType creates lightweight semantic distinctions over int, str, and other existing types. It is ideal for IDs, codes, units, and values that share a representation but must not be mixed.
The official Python NewType documentation defines its semantics. Combine NewType with validated boundary factories, and choose value classes when you need behavior, strong runtime invariants, or real runtime identity.







