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.
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.
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 = intaccepts anyint; no error is ever raised. UseNewTypewhen separation must be enforced. - Calling
isinstance(x, UserId).NewTyperesults are not classes; this raisesTypeErrorat runtime. Check against the base (isinstance(x, int)) instead. - Subclassing a
NewType.class Admin(UserId): ...fails —NewTypeoutput cannot be subclassed; mypy reportsCannot subclass "UserId"[misc]. - Forgetting to construct at the boundary. Passing a raw
intinto aUserIdparameter is mypy[arg-type]/ pyrightreportArgumentType; wrap it once withUserId(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.