Python NewType: Keep IDs Distinct

Published on: August 29, 2026
Reading time: 5 minutes
Detailed view of programming code in a dark theme on a computer screen.

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 statically

Both 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 error

Runtime 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))  # int

The 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 appropriate

The 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 = int

This 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: UserId

Fields 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):
    pass

A 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 + 1

The 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.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Close view of a python resting on sandy terrain outdoors in natural habitat.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Never: Mark Unreachable Code

    Learn Python Never for non-returning functions, unreachable code, and exhaustive unions with assert_never, Literal, Enum, and match.

    Ler mais

    Tempo de leitura: 5 minutos
    29/08/2026
    Detailed shot of a Jungle Carpet Python (Morelia spilota cheynei) in its natural habitat.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python Concatenate: Change Parameters

    Learn Python Concatenate to add or hide leading parameters in typed decorators with ParamSpec, contexts, locks, and dependencies.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    A close-up view of a person's hand signing a business contract on a desk with a pen.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python ParamSpec: Preserve Signatures

    Learn Python ParamSpec to preserve complete function signatures in decorators, callbacks, async wrappers, and higher-order utilities.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    Close-up of hands typing on a laptop keyboard, Python book in sight, coding in progress.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python TypeIs: Narrow Both Branches

    Learn Python TypeIs to narrow true and false branches, compare it with TypeGuard, and build sound reusable type predicates.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    Hands typing code on a laptop in a workspace. Indoor setting focused on software development.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python TypeGuard: Refine Types Safely

    Learn Python TypeGuard to narrow types and validate collections, TypedDict, Protocol, and external data with runtime checks and static safety.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    Close-up view of a computer screen displaying code in a software development environment.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python typing.Self: Fluent Return Types

    Learn Python typing.Self for fluent methods, classmethods, builders, clones, Protocol, context managers, generics, and subclass-preserving returns.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026