NewType vs Type Alias for IDs

TL;DR — UserId = NewType("UserId", int) creates a distinct type: mypy and pyright reject a bare int — or a different ID type — where UserId is expected, catching argument-order and identifier-mixing bugs at check time with no runtime cost. A plain type alias (UserId = int) is transparent: it is fully interchangeable with int, so it documents intent but enforces nothing.

Domain identifiers are the textbook case for this choice. An OrderRepository that takes a user_id and an order_id — both int at runtime — is one transposed-argument call away from a silent bug. NewType promotes those identifiers into types the checker can tell apart; a type alias only renames int.

Distinct NewType versus transparent type alias A plain alias UserId equals int so int and UserId are interchangeable; NewType UserId is a distinct subtype so a bare int is rejected where UserId is expected while UserId still flows into int. Type alias: UserId = int int UserId same type — interchangeable no error either direction NewType("UserId", int) int UserId UserId → int ✓ int → UserId ✗ [arg-type] distinct subtype, one-way
A type alias keeps UserId equal to int; NewType makes UserId a distinct subtype that flows into int but is not accepted from a bare int.

A transparent type alias enforces nothing

A plain alias binds a name to an existing type. To the checker, UserId and int are the same thing, so nothing stops you from passing a raw int — or an OrderId that is also an alias for int.

# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
UserId = int          # transparent alias — just a nicer name for int
OrderId = int

def load_user(user_id: UserId) -> str:
    return f"user:{user_id}"

load_user(42)                 # accepted — 42 is an int is a UserId
order_id: OrderId = 7
load_user(order_id)           # accepted — OrderId is also just int

Both calls type-check. The alias improves readability and gives you a single place to change the underlying type, but it provides no protection against mixing identifiers.

NewType creates a distinct check-time type

NewType returns a callable that, to the type checker, produces values of a new type that is a subtype of the base. You must call it to construct a value, and the checker refuses to accept a bare base value in its place.

# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from typing import NewType

UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)

def load_user(user_id: UserId) -> str:
    return f"user:{user_id}"        # UserId flows into an int context freely

uid = UserId(42)                    # explicit construction
load_user(uid)                      # ✓
load_user(42)                       # error: Argument 1 has incompatible type "int";
                                    #        expected "UserId"  [arg-type]
load_user(OrderId(7))               # error: incompatible type "OrderId"; expected "UserId"  [arg-type]
# pyright reports both as reportArgumentType

The distinctness runs one way. A UserId is usable anywhere an int is expected (arithmetic, formatting, dict keys), because it is a subtype of int. Only the reverse — using a raw int as a UserId — is blocked. That asymmetry is what catches transposed user_id/order_id arguments while leaving normal integer operations untouched.

Runtime vs static analysis NewType has essentially zero runtime cost: UserId(42) returns 42 unchanged — the identity function — and there is no UserId class at runtime. So isinstance(x, UserId) raises TypeError: isinstance() arg 2 must be a type, and type(UserId(42)) is plain int. All the distinctness lives in the type checker; at runtime a UserId and an int are indistinguishable.

Choosing between them

Reach for NewType when the value is a domain identifier whose confusion with a raw base value — or with another same-based ID — would be a real bug: primary keys, tokens, tenant IDs, currency-minor-unit amounts. It costs one construction call at each boundary in exchange for check-time separation.

Reach for a type alias when you only want a readable name for a structural type, or a single edit point, and interchangeability with the base is desirable — for example Row = dict[str, int] or a ServiceConfig = Mapping[str, str] shorthand reused across a module. Aliases are also the right tool for long union types like JsonScalar = str | int | float | bool | None, where you explicitly want substitutability.

On Python 3.12+ the type statement (PEP 695) creates an explicit alias — type UserId = int — which is still transparent; it is a clearer alias, not a NewType substitute. For the general aliasing toolkit see basic type aliases.

Layering NewType through a data layer

The payoff compounds when identifiers thread through several functions. Construct the NewType once at the boundary where raw data enters — a DB row, a parsed request — and the distinctness propagates for free, so every downstream signature is checked against confusion without a single extra call.

# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from typing import NewType

UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)

class OrderRepository:
    def orders_for(self, user_id: UserId) -> list[OrderId]:
        rows = self._query(int(user_id))          # UserId → int at the SQL edge
        return [OrderId(r["order_id"]) for r in rows]

def notify(user_id: UserId, order_id: OrderId) -> None: ...

def handle(raw_user: int, raw_order: int) -> None:
    uid = UserId(raw_user)                          # wrap once at the boundary
    oid = OrderId(raw_order)
    notify(oid, uid)
    # error: Argument 1 to "notify" has incompatible type "OrderId"; expected "UserId"  [arg-type]
    # error: Argument 2 to "notify" has incompatible type "UserId"; expected "OrderId"  [arg-type]

The transposed notify(oid, uid) call is caught the moment it is written — the exact class of bug a transparent alias would wave through. This composes cleanly with the domain-typing approaches in Advanced Typing Patterns & Generics when you later need validated or annotated identifiers.

Common mistakes

  • Expecting a type alias to catch mixed IDs. UserId = int accepts any int; no error is ever raised. Use NewType when separation must be enforced.
  • Calling isinstance(x, UserId). NewType results are not classes; this raises TypeError at runtime. Check against the base (isinstance(x, int)) instead.
  • Subclassing a NewType. class Admin(UserId): ... fails — NewType output cannot be subclassed; mypy reports Cannot subclass "UserId" [misc].
  • Forgetting to construct at the boundary. Passing a raw int into a UserId parameter is mypy [arg-type] / pyright reportArgumentType; wrap it once with UserId(value) where the raw value enters your domain.

FAQ

Does NewType slow my code down? No measurable cost. The constructor is the identity function and returns the argument unchanged; the whole mechanism exists only for the static checker. There is no wrapper object and no isinstance-able class at runtime.

Can I do arithmetic on a NewType of int? Yes — a UserId is a subtype of int, so uid + 1 type-checks and runs. Note the result is typed int, not UserId; re-wrap with UserId(...) if you need the distinct type back.

Back to Basic Type Aliases