Annotated vs NewType for Domain Types

TL;DR

NewType("UserId", int) makes a distinct type the checker enforces — passing a raw int where a UserId is expected fails with mypy [arg-type]. Annotated[int, ...] keeps the same type and only attaches runtime metadata; a raw int is always accepted. Use NewType to stop mixing UserId with OrderId; use Annotated to carry validation or units. They compose: Annotated[NewType, Gt(0)] gives you both.

Both tools model “an int that means something specific,” but they solve opposite problems. NewType is about distinctness — making the checker treat two identically-shaped values as incompatible. Annotated is about transparency — keeping the type identical while smuggling data to the runtime. Picking the wrong one either lets bugs through or breaks arithmetic. This page, part of Advanced Typing Patterns & Generics, contrasts the two with domain-realistic identifiers and shows how to combine them.

NewType distinctness vs Annotated transparency NewType UserId is a distinct subtype: a raw int is rejected with arg-type. Annotated int with Gt(0) is the same type as int: a raw int is accepted and the metadata is carried to the runtime. NewType — distinct UserId = NewType("UserId", int) checker: UserId is NOT int raw int rejected: [arg-type] must wrap: UserId(42) stops UserId vs OrderId mixups Annotated — transparent Annotated[int, Gt(0)] checker: still exactly int raw int accepted metadata read at runtime carries validation / units
NewType buys checker-enforced distinctness; Annotated buys runtime-readable metadata with no type change.

NewType makes a type the checker keeps separate

NewType returns an identity function at runtime but a distinct type at analysis time. The checker refuses to substitute the base type for it, which is exactly what you want to keep two ID kinds from being swapped.

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

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

def cancel_order(order_id: OrderId) -> None: ...

uid = UserId(7)
oid = OrderId(7)

cancel_order(oid)     # ok
cancel_order(uid)     # mypy: [arg-type]; pyright: reportArgumentType
cancel_order(7)       # mypy: [arg-type] — a raw int is not an OrderId

uid and oid are both int at runtime, yet the checker treats them as unrelated. Passing uid or a bare 7 where an OrderId is required is the mistake NewType exists to catch. The dedicated NewType vs type alias for IDs page covers why a plain type alias can’t do this.

Annotated changes nothing the checker enforces

Annotated is the opposite. The checker discards the metadata and sees the base type, so a raw value is always accepted and no distinctness is created.

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import Annotated

Celsius = Annotated[float, "unit: degrees C"]
Fahrenheit = Annotated[float, "unit: degrees F"]

def set_thermostat(target: Celsius) -> None: ...

set_thermostat(21.5)                 # ok — Celsius is just float
f: Fahrenheit = 70.0
set_thermostat(f)                    # ok too — both are float to the checker!

Note the trap on the last line: Celsius and Fahrenheit carry different unit strings, but the checker sees two floats and accepts the mix. Annotated will not stop a unit error — its metadata is inert to mypy and pyright. If you genuinely need the checker to reject a Fahrenheit where a Celsius is expected, that is a NewType (or distinct-class) job, not an Annotated one.

The decision, in one line each

  • Need the checker to reject a raw int or a different ID kind? Use NewType.
  • Need to attach validation, units, or framework hints while keeping arithmetic and assignability intact? Use Annotated.
  • Annotated never restricts; NewType never carries metadata.

Combining them for validated, distinct IDs

The two compose cleanly: wrap a NewType in Annotated to get a checker-enforced distinct type that also carries a runtime rule. Frameworks read the extras; the checker still enforces distinctness.

# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from dataclasses import dataclass
from typing import Annotated, NewType, get_type_hints, get_args

@dataclass(frozen=True)
class Gt:
    bound: int

UserId = NewType("UserId", int)
ValidatedUserId = Annotated[UserId, Gt(0)]

def audit(actor: ValidatedUserId) -> None: ...

audit(UserId(1))     # ok
audit(1)             # mypy: [arg-type] — still must be a UserId

hints = get_type_hints(audit, include_extras=True)
base, *extras = get_args(hints["actor"])
print(base.__name__, extras)     # UserId [Gt(bound=0)]

The checker sees UserId (distinct, so 1 is rejected with [arg-type]), while get_type_hints(..., include_extras=True) still recovers Gt(0) for a runtime validator. This is the pattern Pydantic v2 leans on: a distinct domain type on the outside, constraint metadata riding inside. For the runtime-consumer half, see using Annotated for validation metadata.

Runtime vs static analysis At runtime, UserId(7) is literally the int 7NewType's call is an identity function and adds no wrapper or class. And Annotated's Gt(0) is inert unless a framework reads it. So the distinctness that stops cancel_order(uid) exists only during type checking; both protections vanish once the program runs.

Common mistakes

  • Using Annotated to prevent mixing IDs. Annotated[int, "user"] and Annotated[int, "order"] are both int to the checker, so it accepts the swap. No [arg-type] fires. Use NewType for distinctness.
  • Subclassing a NewType or calling it in an isinstance. class Admin(UserId) and isinstance(x, UserId) both fail at runtime — NewType results are not real classes. mypy also flags the subclass with [valid-type]/[misc].
  • Expecting NewType to validate. UserId(-5) is happily created; NewType enforces distinctness, not value rules. Pair it with Annotated metadata and a runtime validator for constraints.
  • Forgetting include_extras=True on the combined form. get_type_hints(audit) returns UserId with the Gt(0) stripped, so the validator sees no constraint. Pass include_extras=True to recover the metadata.

FAQ

Which should I use for an OrderId? NewType, if the goal is to stop an OrderId being passed where a UserId (or a raw int) belongs — that distinctness is exactly what NewType gives you and Annotated does not.

Can I get both distinctness and validation metadata? Yes — nest them as Annotated[NewType("UserId", int), Gt(0)]. The checker enforces the distinct UserId and rejects raw ints, while get_type_hints(..., include_extras=True) still exposes Gt(0) to a runtime validator.

Back to Annotated Types and Metadata