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.
The distinctness is directional, which trips people up. A NewType is treated as a subtype of its base, so the substitution is allowed one way only: you can pass a UserId where a plain int is expected, but not a plain int where a UserId is expected. That asymmetry is what lets UserId flow into len, arithmetic, or a dict[int, ...] lookup while still blocking the raw-int-as-UserId mistake.
def audit_log(n: int) -> None: ... # accepts any int
audit_log(uid) # ok — UserId is a subtype of int
total: int = uid + 1 # ok — arithmetic widens back to int, not UserId
UserId(uid) # ok — re-wrapping a UserId is fine
Note that uid + 1 is typed int, not UserId: any operation that returns the base type drops the distinctness, so you re-wrap (UserId(uid + 1)) when you need to preserve it. NewType also imposes hard runtime and static limits. You cannot subclass it (class Admin(UserId): ... is a runtime TypeError and mypy flags [valid-type]/[misc]), you cannot use it in isinstance(x, UserId) (there is no class to test against), and it performs no validation — UserId(-1) and UserId(7) are equally valid. Since Python 3.10 NewType is implemented as a class (previously a closure) purely for speed; the callable still just returns its argument, and each result carries a __supertype__ attribute (UserId.__supertype__ is int) that tools can introspect. Because a NewType is defined by assignment, from __future__ import annotations has no effect on it — the base type is resolved eagerly when the module runs, not lazily like a string annotation.
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 reduction rule is defined by PEP 593: Annotated[T, x] is treated as T for every static operation — assignability, overload matching, reveal_type, and inference all behave as if the metadata were absent. reveal_type(target) inside set_thermostat prints builtins.float, not Celsius. Two consequences follow. First, nested Annotated flattens and de-duplicates the type but keeps all metadata: Annotated[Annotated[float, "a"], "b"] is Annotated[float, "a", "b"], still a float. Second, equality is by base type plus metadata tuple, so Annotated[float, "degrees C"] == Annotated[float, "degrees C"] is True but Celsius == Fahrenheit is False — a difference visible only to runtime code that inspects the annotations, never to the checker. This is precisely why unit-safety libraries reach for NewType or dedicated wrapper classes: only a genuinely distinct type produces an [arg-type] error when the units are crossed.
There is one place Annotated’s metadata does subtly influence tooling: some checkers and libraries treat certain well-known markers specially even though they never change assignability. pyright reads Annotated[T, ...] for its deprecated and ReadOnly handling in TypedDict, and Pydantic’s Field inside Annotated alters validation but never the static type. The rule to internalise is that assignability is decided entirely by the base type T; anything a tool does with the extras is layered on top and never removes an error the base type would have raised, nor adds one the base type would have allowed. If you observe an [arg-type] on an Annotated parameter, it is always the base T talking — inspect get_args(hint)[0], not the metadata.
The decision, in one line each
The choice reduces to a single question — do you want the checker to reject substitutions, or to carry data it will ignore?
- 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.
There is a third branch the two-way split hides: when you need runtime behaviour — real validation, methods, or an invariant that must hold when the program actually runs — neither tool is the answer. NewType adds no runtime class and Annotated adds no runtime enforcement, so reach for a @dataclass(frozen=True) wrapper (which gives you a real isinstance-testable type, __eq__, and a __post_init__ for validation) or an Enum for a closed set of values. The cost is that a wrapper is a genuinely different object at runtime, so UserId(7) + 1 no longer works without unwrapping. NewType’s appeal is precisely that it is free at runtime — zero allocation, full int behaviour — which is why it dominates for high-volume identifiers where a wrapper class would add measurable overhead. IDE support differs too: because UserId reads as a named symbol, editors surface it in hovers and autocomplete, whereas a bare Annotated[int, ...] alias typically shows as int.
One tempting non-answer deserves a mention: the Python 3.12 type statement (PEP 695), e.g. type UserId = int, does not create distinctness either — it is an explicit type alias, so a raw int is accepted exactly as with the old UserId: TypeAlias = int. Aliases, whether written with =, TypeAlias, or the new type keyword, are transparent by design; only NewType (or a real class) is opaque to substitution. So on modern Python you still have three tiers: type X = int for a transparent rename, Annotated[int, ...] for a transparent rename that also carries metadata, and NewType("X", int) for an opaque, checker-enforced distinct type. Pick the tier by how much the checker should push back, and remember that pushing back is something only the third tier ever does.
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.
The nesting order is not a free choice. Annotated[UserId, Gt(0)] works, but NewType("UserId", Annotated[int, Gt(0)]) does not — NewType’s second argument must be a “proper class”, and an Annotated form is not one, so mypy reports [valid-type] and pyright rejects it. Put the NewType on the inside and Annotated on the outside. Because Annotated flattens, you can layer several rules and even alias the whole thing: ValidatedUserId = Annotated[UserId, Gt(0)] reads as a single named type everywhere it appears. When the runtime splits it with get_args, the first element is the UserId NewType object (test it with base is UserId, and recover its base with base.__supertype__), and the remaining elements are your rule objects in declaration order. One subtlety for consumers: get_type_hints(..., include_extras=True) resolves string annotations and returns the Annotated form, whereas reading the raw __annotations__ dict under from __future__ import annotations yields the unresolved string "ValidatedUserId" — always go through get_type_hints so both the NewType and its metadata are materialised.
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
Most NewType/Annotated bugs come from expecting one tool to do the other’s job. The matrix below pairs each mistake with what the checker or runtime actually does and the fix.
- 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]. If you need a subclassable,isinstance-testable type, use a@dataclass(frozen=True)or aclass UserId(int)subclass instead. - 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. - Re-wrapping too late. Any arithmetic on a
NewType(uid + 1) widens the result back to the baseint; if you store it without re-wrapping (UserId(uid + 1)), later call sites lose the distinctness silently — the checker will not warn, becauseintis assignable into the wider positions it flows through.
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.