Complex type expressions repeat across signatures and turn into noise fast. A dict[str, list[tuple[int, float]]] parameter type tells the reader almost nothing about what's in there. Type aliases give that shape a readable name, and NewType goes one step further by creating distinct types that the checker treats as separate, even when they share a runtime representation. This lesson covers the four ways to name a type in modern Python and the one tool that turns same-looking primitives into checkable, distinct identities.
Look at this function signature for an inventory system that groups products by category, with each product carrying a stock count and a price:
The annotation is technically correct, but a reader has to parse it like an algebra problem. What's the str key? What does the tuple represent? You have to read the function body or the docstring to find out. Now compare it with named pieces:
The signature reads like English. Inventory is what goes in, category picks one out. The reader doesn't need to remember the underlying shape unless they're actually modifying it.
Naming a type does three things at once:
Cents is more informative than int everywhere it shows up.Price should be a Decimal instead of a float, you update one alias, not forty function signatures.NewType, you can get the checker to flag add_to_cart(customer_id) when the function actually wanted a ProductId.The first two points work with plain aliases. The third needs NewType, which is the more interesting half of this lesson.
A type alias in its simplest form is just a variable assignment at module scope:
The checker treats ProductId and int as the same type. You can pass an int literal where a ProductId is expected, and vice versa, without complaint. The alias is a documentation tool here, nothing more.
This form has worked since type hints landed in Python 3.5. It uses no special syntax: the right-hand side is any valid type expression, and the left-hand side is the name you want to use. The interpreter at runtime sees a normal variable assignment. The type checker recognizes it as an alias because the value on the right is a type.
A few common shapes in an e-commerce codebase:
Every one of those is interchangeable with its underlying type from the checker's perspective. The win is purely readability.
Plain assignments work, but they have an ambiguity problem. Look at this:
Both lines are assignments. The first is meant as a type alias; the second is a module-level constant. A type checker has to use heuristics to decide which is which. Most of the time it gets it right, but edge cases exist. What about this?
The two lines look almost identical, and yet the intent is different. PEP 613, shipped in Python 3.10, gave us a way to be explicit:
The : TypeAlias annotation tells the checker, "this is an alias, no guessing required." It has no runtime effect; the variable still holds the same value it would have without the annotation. But to a checker, the intent is unambiguous, and tooling can flag mistakes like later reassigning ProductId to a non-type value.
There's a subtler benefit: forward references. A plain assignment can't easily refer to a name defined later in the same module without using string quotes. With TypeAlias, both string forms and direct references behave predictably:
Without the annotation, a checker might not be sure whether the string is a type or just a string. With TypeAlias, the meaning is fixed.
You don't need TypeAlias everywhere. If your aliases are simple and live in a module of pure type definitions, plain assignments are fine. Reach for TypeAlias when:
In a Python 3.12 codebase, you'll skip TypeAlias entirely and use the type statement instead, which we'll get to next.
Python 3.12 introduced a new syntax specifically for declaring type aliases, via PEP 695. It looks like this:
The type keyword is now an actual statement. Three things are different from plain assignments and TypeAlias:
type is a built-in keyword, not a typing import.TypeAliasType object at runtime, similar to how a class creates a type object.The lazy evaluation point is the most practical. Consider mutually recursive types, which used to be awkward:
The right-hand side references NestedCategory itself, but Python doesn't try to resolve it until the type is actually inspected. With plain assignments or TypeAlias, this would require quoting the name.
The type statement is the recommended form going forward. If your codebase is on 3.12 or newer, use it by default and fall back to the older forms only when supporting older versions matters. Here's a visual summary of how the three alias forms map to Python versions and what they buy you:
All three are aliases from the checker's perspective: the named type is identical to whatever it points at. None of them creates a distinct type. For that, you need NewType.
Here's where things get interesting. An alias makes UserId mean int to both the reader and the checker. A NewType makes UserId mean int at runtime but a distinct subtype at check time:
The checker now treats UserId and OrderId as different types, even though both are int at runtime. Passing a UserId to a function expecting OrderId becomes a static error. The bug that's almost impossible to spot in a plain-int codebase, accidentally swapping two integer IDs, becomes a one-line complaint from your type checker.
The pattern for using a NewType is simple:
UserId = NewType("UserId", int).user = UserId(42).def lookup(user: UserId) -> Customer: ....The constructor call at runtime is a thin wrapper. It returns the argument unchanged. There's no allocation, no wrapper object, no overhead beyond a function call. From the interpreter's perspective, UserId(42) is 42. From the checker's perspective, it's a value of type UserId, which is a subtype of int.
The subtype direction is important. Because UserId is a subtype of int, you can pass a UserId to any function that takes a plain int:
You can't go the other way. A plain int is not a UserId. Functions that demand a UserId reject raw ints:
At runtime, both calls succeed; the checker is the one keeping you honest. The pattern forces every call site to be explicit about which kind of integer it's handing over.
A diagram makes the runtime-vs-checker split easier to remember:
The left side, runtime, sees an integer. The right side, the type checker, sees a distinct type. Both views coexist without conflict because the checker never runs your code and the runtime never looks at your annotations.
Cost: NewType calls are O(1) and don't allocate. The constructor is implemented as a function that returns its argument unchanged. Wrapping every ID assignment in UserId(...) costs essentially nothing at runtime.
This is the table to remember. The decision between the two forms turns on whether you want the checker to enforce a distinction or just provide a readable label:
| Feature | Type Alias | NewType |
|---|---|---|
| Declaration | Price = float or type Price = float | Price = NewType("Price", float) |
| Runtime cost | Zero (no object) | Tiny function call (returns argument) |
| Checker view | Same as the original type | Distinct subtype |
| Mixing with original | Free, interchangeable | One-way: subtype to base allowed, not reverse |
| Catches ID swaps? | No | Yes |
| Can add methods? | No | No |
| Generic support | Yes (type Cart[T] = dict[int, T]) | Limited; not a real class |
| Construction | None required | Explicit: Price(29.99) |
| Best for | Readable shorthand for complex types | Distinguishing same-shaped primitives |
The two tools solve different problems. An alias makes a long type expression short. A NewType makes a primitive type more specific. You can use both in the same codebase, and a single domain concept might pick either depending on whether mix-ups are likely.
Concrete examples of the decision:
ProductMap is just a shape. Nothing's going to confuse a ProductMap with another dict[int, str] because there's no other dict[int, str] floating around in the same scope. UserId and OrderId, on the other hand, are both int and live in the same functions all the time. The checker enforcement is the whole point.
A useful framing: aliases solve a readability problem, NewType solves a correctness problem. If you find yourself adding lots of comments that say "this is the customer id, not the order id," you have a NewType use case.
The strongest case for NewType is domain primitives: things that are technically int or str but represent meaningfully different concepts in your domain. An e-commerce backend is full of them.
Money is a classic example. Half the bugs in financial code come from mixing dollars and cents, or worse, treating two currencies as interchangeable:
Every conversion between units now has to go through an explicit function. The checker won't let you pass a Dollars to charge_cents no matter how many times you write 29.99. Bugs that historically required code review and integration tests to catch become syntactically impossible.
The same pattern fits IDs of any kind:
The whole flow gets typed properly. Even when a function happens to take and return integers, the checker tracks which kind of integer is going where.
Validated strings are another fit. An Email is a str, but a str is not necessarily a valid Email. By making Email a NewType and only constructing it through a validator, you push correctness into the type system:
Now every place that takes an Email has a runtime guarantee that the value passed validation, because the only way to get an Email is through the validator. The type system documents an invariant the codebase actually upholds.
Where NewType doesn't fit:
URL newtype over str makes sense; a Person newtype over a dataclass doesn't, because you'd just use a subclass.NewType does nothing useful at runtime. If no one runs mypy or pyright, the constructor calls are a tax that buys nothing.NewType is intentionally minimal. It is not a class, and that bounds what you can do with it.
You can't add methods or attributes:
If you find yourself wanting methods, you've outgrown NewType. Either use a real class:
Or use a dataclass if the value carries more than one field. The boundary is: NewType for "this is a kind of int", a real class for "this has behavior or structure of its own."
You also can't use NewType in isinstance checks:
At runtime, UserId is a function, not a type. isinstance(value, UserId) doesn't make sense because there's no class to check against. If you need a runtime check, check against the underlying type (isinstance(value, int)) or use a real class.
NewType doesn't compose with Generic either. You can't write NewType("Cart", dict[int, T]) with a TypeVar T and expect it to behave like a generic class. For generic distinct types, you'd write a real generic class or use a generic type alias instead.
Note: NewType over NewType works, but it's rare. AdminUserId = NewType("AdminUserId", UserId) creates a distinct subtype of UserId, which is itself a subtype of int. An AdminUserId can be passed where UserId is expected, which can be passed where int is expected. The chain holds.
For generic aliases, the type statement in 3.12+ has its own syntax:
Wait, that output is wrong. Let me show the actual behavior:
Cart[T] is a generic alias parameterized by T. Each instantiation gives you a different concrete shape. The generic alias is still an alias; values of Cart[float] and dict[int, float] are interchangeable as far as the checker is concerned.
10 quizzes