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.

NewType at type-check time versus at runtime UserId equals NewType of int flows two ways: the static checker treats UserId as a distinct subtype of int, while at runtime UserId of 7 is the plain integer 7 returned by an identity function. UserId = NewType("UserId", int) one name, two behaviours Type-check time UserId is a distinct subtype of int raw int rejected: [arg-type] Run time UserId(7) is exactly the int 7 identity call, no wrapper class
NewType splits by audience: a distinct subtype for the checker, an identity function at runtime.
# 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 validationUserId(-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.

Annotated aliases collapse to one base type Celsius and Fahrenheit are both Annotated of float with different unit strings, but the type checker maps both to the identical plain float type, so it cannot tell them apart. Celsius Annotated[float, "degrees C"] Fahrenheit Annotated[float, "degrees F"] float checker sees one type no distinctness — the mix is accepted
Both aliases reduce to the same float, so the checker cannot reject a Fahrenheit-for-Celsius swap.
# 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?

Choosing NewType, Annotated, or both Starting from the question of what you need, a yes to checker-enforced distinctness leads to NewType, a yes to runtime metadata leads to Annotated, and needing both leads to Annotated wrapping a NewType. What do you need? distinctness vs metadata reject raw int / other ID? → NewType carry units/rules? → Annotated both at once? → Annotated[NewType, Rule] Need behaviour or invariants enforced at runtime instead? reach for a frozen dataclass or an Enum, not NewType
One question — enforce distinctness, carry metadata, or both — routes you to the right tool.
  • 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.

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.

Stacking Annotated over NewType over int Three stacked layers show int as the base type, NewType adding checker-enforced distinctness on top, and Annotated adding a runtime Gt(0) rule as the outermost layer. int — the base type real runtime value, full arithmetic NewType("UserId", int) checker-enforced distinctness — rejects raw int Annotated[..., Gt(0)] runtime rule read via get_type_hints
Each layer adds one capability: int gives value, NewType gives distinctness, Annotated gives a runtime rule.
# 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 notNewType’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.

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

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.

Mistakes, symptoms, and fixes A three-column matrix lists four common mistakes, the symptom each produces, and the correct fix, such as using NewType for distinctness and Annotated plus a validator for constraints. Mistake What actually happens Fix Annotated to keep IDs apart both are int — swap accepted use NewType subclass / isinstance a NewType not a real class — TypeError wrap in a dataclass expect NewType to validate UserId(-5) created happily Annotated + validator drop include_extras=True Gt(0) silently stripped pass include_extras=True Each row: wrong tool for the job → fix by matching tool to intent
Every row is a case of asking distinctness of Annotated or validation of NewType — match the tool to the intent.
  • 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]. If you need a subclassable, isinstance-testable type, use a @dataclass(frozen=True) or a class UserId(int) subclass instead.
  • 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.
  • Re-wrapping too late. Any arithmetic on a NewType (uid + 1) widens the result back to the base int; if you store it without re-wrapping (UserId(uid + 1)), later call sites lose the distinctness silently — the checker will not warn, because int is 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.

Back to Annotated Types and Metadata