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.

A transparent alias has no gate Because UserId is just int to the checker, a literal 42, an int and an OrderId all flow into a UserId parameter with no error. 42 (int literal) n: int OrderId = int no gate — all accepted load_user(user_id: UserId) UserId is int — every call type-checks
To the checker a transparent alias is its base type, so every candidate value flows in unchecked.
# 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.

NewType as a one-way gate UserId flows freely into any int context, but a bare int is rejected where a UserId is expected unless it is explicitly wrapped with UserId(). UserId distinct subtype of int int base type UserId used as int ✓ int used as UserId ✗ [arg-type] the only way across the gap: call UserId(value)
A UserId is usable wherever an int is; only the reverse is gated behind an explicit UserId(...) 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)

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.

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.

NewType or type alias? If confusing the value with its base or another same-based value would be a real bug, use NewType; if interchangeability is desirable, use a transparent alias. Would mixing it up be a bug? user_id vs order_id, token vs raw str yes no NewType primary keys, tokens, tenant IDs, money-minor check-time separation, one wrap per boundary Type alias dict/union shorthand, one edit point interchangeable with base, zero enforcement
Pick NewType when confusion is a bug worth blocking; pick an alias when interchangeability is the goal.

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.

Wrap once at the boundary, propagate for free A raw int enters at the edge, is wrapped once into a UserId, and that distinct type flows through the repository and notify calls without further construction. raw int request / DB row UserId(raw) wrap once orders_for(UserId) checked, no wrap notify(UserId) checked one construction at the edge; distinctness carries downstream for free
Wrap the raw value once where it enters; the distinct type is checked through every hop with no further calls.
# 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.

NewType mistakes, outcomes, and fixes Using an alias to catch mixed IDs, calling isinstance on a NewType, subclassing one, and forgetting to construct each map to an outcome and a corrective fix. Mistake Outcome Fix alias to catch mixed IDs no error ever raised use NewType isinstance(x, UserId) TypeError at runtime isinstance(x, int) class Admin(UserId) [misc] cannot subclass chain: NewType on NewType pass raw int to UserId param [arg-type] at the call wrap once: UserId(value)
Each mistake has a single mechanical remedy — construct at the boundary, check against the base, and chain rather than subclass.
  • 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: isinstance() arg 2 must be a type... 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]. Chain with AdminId = 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 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