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.
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.
Transparency runs in both directions and is total: UserId accepts any int, and any UserId is accepted anywhere an int is wanted, because the two names denote the same type object. For a structural alias that is exactly the point — an alias for dict[str, list[int]] should be freely interchangeable with the thing it names — but for a domain identifier it is precisely the safety you are missing. Two properties are worth separating: an alias gives you a single edit point (change UserId = int to UserId = str in one place) and documentation value (a signature reading Mapping[UserId, Account] is clearer than Mapping[int, Account]). Neither of those requires — or provides — nominal distinctness.
Related tools are transparent in the same way and are easy to reach for by mistake. The Python 3.12 type statement (type UserId = int) is a clearer, lazily-evaluated spelling of the alias, not a stricter one, so it accepts a bare int just as readily. Nor does Annotated change the type: UserId = Annotated[int, "user id"] attaches metadata for frameworks to read while leaving the underlying type equal to int, so a checker still lets a plain integer through. Of the common options, only NewType yields a genuinely distinct type the checker will police.
Crucially, no strictness setting closes this gap. Even under mypy --strict or pyright’s strict mode a transparent UserId = int still accepts a transposed argument, because strictness governs coverage — untyped defs, implicit Any, unused ignores — not nominal distinctness. Both names denote one type, so there is nothing for a stricter checker to flag:
# mypy --strict / pyright strict — still no error
UserId = int
OrderId = int
def transfer(src: UserId, dst: OrderId) -> None: ...
transfer(7, 3) # accepted: 7 and 3 are ints, so both are UserId and OrderId
That silent acceptance is the precise blind spot NewType removes.
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.
The implementation is deliberately minimal, and it changed for the better in Python 3.10. Before then NewType(name, base) returned a small pure-Python closure; since 3.10 it returns an instance of a dedicated typing.NewType class whose __call__ is the identity function. The newer form constructs faster and exposes __name__, __qualname__, __module__, and __supertype__ for introspection, so you can recover the base with UserId.__supertype__ (which is int). The returned value is never wrapped: UserId(42) is the very same object 42.
You can layer identifiers by wrapping one NewType in another, and the checker tracks the result as a chain of distinct subtypes:
# A NewType can wrap another NewType
from typing import NewType
UserId = NewType("UserId", int)
AdminId = NewType("AdminId", UserId) # AdminId <: UserId <: int
def ban(admin: AdminId) -> None: ...
ban(UserId(1)) # error: expected "AdminId", got "UserId" [arg-type]
ban(AdminId(UserId(1))) # ✓ construct through the chain
You can make the distinctness visible with reveal_type: inside a function taking user_id: UserId, reveal_type(user_id) prints Revealed type is "UserId", whereas under a transparent alias the same call prints "int". Same runtime value, different static identity — that contrast is the entire point of the feature.
The base must be subclassable, though. NewType("Weird", int | str) — or a base that is a Union, Any, a Protocol, or a type alias resolving to a union — is rejected: mypy reports “Argument 2 to NewType(…) must be subclassable” [valid-newtype], and pyright flags reportGeneralTypeIssues. Wrap a single concrete class, or build a chain of NewTypes when you need layered identifiers. This is the key structural difference from a plain alias, which happily names a union.
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.
A useful third axis is whether you need metadata rather than a distinct type. If the goal is to carry validation constraints or serialization hints alongside a value that should stay interchangeable with its base — a PositiveInt a validator understands, say — reach for Annotated instead: it keeps the type equal to int while attaching the metadata frameworks like Pydantic read. NewType and Annotated even compose: UserId = NewType("UserId", int) gives you the distinct type, and Annotated[UserId, Field(gt=0)] layers a constraint on top. The cost side of NewType is real but bounded: it adds one construction call at each boundary where a raw value enters your domain, and its results cannot be used as a Union member’s discriminator or passed to isinstance. When that friction outweighs the safety — throwaway scripts, values that never get mixed — a plain alias or no alias at all is the pragmatic choice.
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.
The boundaries deserve care because that is where the distinct type meets the untyped world. Convert out explicitly when a value crosses back into a raw-int API — int(user_id) at the SQL edge — even though the value is already an int at runtime, because the cast documents intent and keeps the checker’s view honest. Convert in exactly once per identifier: a comprehension like [OrderId(r["order_id"]) for r in rows] re-establishes the distinct type on data arriving from an untyped source. Serialization is a non-event: because NewType adds no wrapper, json.dumps({"user_id": user_id}) emits a plain integer and an ORM stores an ordinary column — nothing downstream needs to know the type ever existed. The one recurring surprise is arithmetic: user_id + 1 type-checks (a UserId is an int) but its result is typed int, not UserId, so if you need the distinct type back you must re-wrap with UserId(user_id + 1). The same asymmetry shows up in containers and dataclasses: a field annotated user_id: UserId on a dataclass accepts only a UserId, so your construction sites must wrap, but list(user_ids) and max(user_ids) fall back to int because those operations are defined on the base. Keeping the wrap at the domain boundary — and re-wrapping only where you genuinely need the distinct type again — keeps that friction to a minimum while preserving the check-time separation everywhere it matters.
Common mistakes
These four account for nearly every NewType problem in practice; each has a mechanical fix.
- 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 raisesTypeError: isinstance() arg 2 must be a type...at 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]. Chain withAdminId = NewType("AdminId", UserId)instead. - Wrapping a union or
Any.NewType("X", int | str)is rejected with[valid-newtype]because the base must be subclassable. Wrap one concrete class, and use a plain alias for the union. - 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.