Annotated vs NewType for Domain Types
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 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
intor a different ID kind? UseNewType. - Need to attach validation, units, or framework hints while keeping arithmetic and assignability intact? Use
Annotated. Annotatednever restricts;NewTypenever 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.
UserId(7) is literally the int 7 — NewType'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
Annotatedto prevent mixing IDs.Annotated[int, "user"]andAnnotated[int, "order"]are bothintto the checker, so it accepts the swap. No[arg-type]fires. UseNewTypefor distinctness. - Subclassing a
NewTypeor calling it in anisinstance.class Admin(UserId)andisinstance(x, UserId)both fail at runtime —NewTyperesults are not real classes. mypy also flags the subclass with[valid-type]/[misc]. - Expecting
NewTypeto validate.UserId(-5)is happily created;NewTypeenforces distinctness, not value rules. Pair it withAnnotatedmetadata and a runtime validator for constraints. - Forgetting
include_extras=Trueon the combined form.get_type_hints(audit)returnsUserIdwith theGt(0)stripped, so the validator sees no constraint. Passinclude_extras=Trueto 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.