Back to Blog
Python

Using Python NewType for Type Safety

python newtype: Learn how Python's NewType creates distinct types for better type checking, its runtime behavior, and when to use it over subclassing.

typingtype hintstype safetymypypython
Illustration of Python NewType creating distinct types from a base type, shown as a type-checking shield over primitive values.

Python's NewType helper, introduced in PEP 484, lets you create distinct types that are only meaningful to static type checkers like mypy. It gives you a way to express that a value is not just an int or a str, but a specific kind of int or str that should not be mixed with others in your code. This article explains what python newtype does, how to use it, and where it fits in your type design.

What NewType Does in Python's Type System

NewType is a function in the typing module that returns a callable. When you call it, you pass a name and an underlying type. The returned object can be used as a type annotation and also as a runtime callable that simply returns its argument unchanged.

from typing import NewType UserId = NewType('UserId', int)

Now UserId is a type that is distinct from int in the eyes of a static type checker. A variable annotated as UserId can only be assigned a value that was explicitly created via UserId(...) or another value already typed as UserId. This prevents accidental mixing of, say, a raw integer that represents a database primary key with one that represents a user ID.

The type checker treats UserId as a subtype of int, but it does not allow implicit conversion from int to UserId. This is the core safety property: you must explicitly cast or construct a UserId value.

Defining a NewType and Using It in Annotations

Defining a NewType is a single line, and then you use it in function signatures and variable annotations just like any other type.

from typing import NewType UserId = NewType('UserId', int) def get_user(user_id: UserId) -> str: # ... return f"user {user_id}"

When you call get_user, you must pass a UserId, not a plain int:

user_id = UserId(42) get_user(user_id) # OK get_user(42) # type error: Argument 1 to "get_user" has incompatible type "int"; expected "UserId"

This is the primary benefit: the type checker enforces that you don't accidentally pass a raw integer where a user ID is expected. The runtime behavior is that UserId(42) simply returns 42, so there is no runtime cost or conversion.

Runtime Behavior: What NewType Actually Returns

At runtime, NewType returns a function that returns its argument unchanged. It does not create a new class, does not wrap the value, and does not add any attribute or method. The returned callable is essentially an identity function with a specific name.

from typing import NewType UserId = NewType('UserId', int) print(UserId(42)) # 42 print(type(UserId(42))) # <class 'int'>

This means that UserId(42) is 42 is True for small integers due to interning, but that is an implementation detail. The key point is that UserId does not create a distinct runtime object. It is purely a typing construct.

Because of this, you cannot use isinstance with a NewType:

isinstance(42, UserId) # TypeError: isinstance() arg 2 must be a type

UserId is not a class; it is a function. This is a common source of confusion for developers who expect a NewType to behave like a subclass.

NewType vs Subclassing: Choosing the Right Approach

When you need distinct types, you have two main options: NewType or subclassing the base type. Each has different tradeoffs.

Subclassing creates a real class that inherits behavior and can have additional methods. For example:

class UserId(int): pass

Now UserId is a real type. You can use isinstance, and values of type UserId are also instances of int. But subclassing has runtime overhead: every time you create a UserId, you allocate a new object of that class. Also, operations like arithmetic may return the base type, not the subclass, depending on how the operation is implemented.

NewType has zero runtime overhead because it does not create a new class. It only affects static type checking. This is ideal when you want type safety without changing runtime behavior.

AspectNewTypeSubclassing
Runtime overheadNone (identity function)Object allocation for new instances
isinstance supportNoYes
Additional methodsNoYes
Type checker distinctionStrong (no implicit conversion)Strong (but implicit conversion from subclass to base is allowed)
Best use caseSimple distinct types for IDs, tags, etc.When you need real behavior or methods

Choose NewType when you only need to distinguish types at the type-checking level and do not need runtime behavior. Choose subclassing when you need to add methods or when the type must be a real class for runtime checks.

When NewType Improves Code Clarity and Safety

NewType is most valuable in codebases where the same underlying primitive type is used for multiple concepts. For example, in a web application, you might have a user ID, an order ID, and a product ID, all represented as integers. Without distinct types, it is easy to pass the wrong ID to a function.

from typing import NewType UserId = NewType('UserId', int) OrderId = NewType('OrderId', int) ProductId = NewType('ProductId', int) def fetch_order(order_id: OrderId) -> Order: ... def fetch_user(user_id: UserId) -> User: ...

Now the type checker will catch mistakes like passing a UserId to fetch_order. This is especially useful in large codebases with many functions and frequent refactoring.

Another common use case is for string-based values like email addresses, phone numbers, or database connection strings. You can define Email = NewType('Email', str) and use it in function signatures to prevent mixing a raw string with a validated email.

Limitations and Common Pitfalls with NewType

NewType is not a silver bullet. There are several limitations you should know.

First, NewType does not enforce any runtime validation. It is purely a typing construct. If you call UserId('not an int'), the runtime will return the string unchanged, and the type checker will not complain because the argument type is not checked at runtime. You must still validate data at runtime separately.

Second, NewType is not a real class, so you cannot use it with isinstance, issubclass, or as a base for another class. This can be surprising when you try to do something like class AdminUserId(UserId): which raises a TypeError because UserId is not a class.

Third, type checkers treat NewType as a subtype of the base type, but they do not allow implicit conversion in the other direction. This means you cannot pass a UserId where an int is expected? Actually, you can, because UserId is a subtype of int. The type checker allows passing a UserId to a function expecting int. This is often fine, but it can lead to losing type information if you pass a UserId to a function that returns int and then use that result as a UserId again.

For example:

def increment(x: int) -> int: return x + 1 user_id = UserId(5) new_id = increment(user_id) # type is int, not UserId

If you then try to pass new_id to a function expecting UserId, you will get a type error. You need to explicitly wrap it again: UserId(new_id). This is a common friction point, but it is intentional because the type checker cannot know that the operation preserves the semantic meaning.

Performance and Type-Checking Considerations

NewType has zero runtime cost because it is just a function call that returns its argument. There is no object allocation, no attribute access, and no extra memory. This makes it ideal for high-performance code where you do not want to pay for subclassing overhead.

The main cost is at type-checking time. Using many NewType definitions can make type checking slightly more complex, but in practice the overhead is negligible. The real benefit is that type errors are caught before runtime, which can save debugging time.

One operational consideration is that NewType is only useful if you actually run a type checker like mypy or Pyright in your continuous integration pipeline. If you do not use type checking, NewType has no effect at runtime and provides no safety. It is purely a development-time tool.

When you use NewType, you should also consider how it interacts with serialization and deserialization. Since NewType does not alter the runtime value, you can serialize a UserId as a plain integer and deserialize it back to an integer, but you will need to wrap it with UserId() after deserialization to maintain type safety in your code.

Practical Example: Using NewType for Database IDs

A typical scenario is using NewType for database primary keys. Suppose you have a SQL database with tables for users and orders. Both have integer primary keys. Without distinct types, you might accidentally pass a user ID to a function that expects an order ID.

from typing import NewType UserId = NewType('UserId', int) OrderId = NewType('OrderId', int) def fetch_user(user_id: UserId) -> dict: # ... def fetch_order(order_id: OrderId) -> dict: # ... def get_user_orders(user_id: UserId) -> list[OrderId]: orders = [] # query database for orders where user_id = user_id # assume we get a list of integer order IDs return [OrderId(oid) for oid in raw_order_ids]

In this example, the function get_user_orders returns a list of OrderId objects, making it clear that the list contains order IDs, not user IDs. The type checker ensures that you cannot accidentally pass an OrderId to fetch_user.

When you read data from the database, you often get raw integers. You must explicitly wrap them with the appropriate NewType to satisfy the type checker. This is a small overhead but it documents the intent and prevents future mistakes.

When Not to Use NewType

NewType is not appropriate when you need runtime validation or when you want to add methods to the type. For example, if you need a UserID class with a method to check its format, you should use a subclass or a custom class. Also, if you need to distinguish between values that have different runtime representations, NewType is insufficient because it does not change the value.

Another case is when you need to perform operations that return a new value of the same type. As shown earlier, arithmetic operations on NewType return the base type, so you lose the type information. If you need to preserve the type through operations, you might need a custom class with overridden operators.

Finally, NewType is not a replacement for proper data validation. It is a type-checking tool, not a runtime validation tool. Always validate external input at the boundary of your system, and use NewType to propagate the validated type through your internal code.

Understanding the Type Checker's View of NewType

To use NewType effectively, it helps to understand how type checkers treat it. In mypy, NewType creates a type that is a subtype of the base type. This means that a UserId is assignable to an int variable, but an int is not assignable to a UserId variable without an explicit cast.

This asymmetry is intentional. It prevents you from accidentally using a raw integer where a specific ID is expected, while still allowing you to pass the ID to functions that accept the base type. For example, you can pass a UserId to a function that expects an int for logging or arithmetic, but you cannot pass an int to a function that expects a UserId without wrapping it.

This behavior is consistent across major type checkers like mypy, Pyright, and Pyre. However, there are subtle differences in how they handle NewType in certain edge cases, such as when using it with generic types or with TypeVar. In most practical cases, the behavior is the same.

One important detail is that NewType is not a class, so you cannot use it in isinstance checks or as a base class. This is a deliberate design choice to keep the runtime behavior minimal. If you need these features, you should use a class instead.

Advanced Usage: NewType with Generic Types

NewType can also be used with generic types, but there are some caveats. For example, you can create a NewType for a list of integers:

from typing import NewType, List IntList = NewType('IntList', List[int])

Now IntList is a distinct type from List[int]. You can use it in annotations, and the type checker will enforce that you pass a value that was explicitly created as IntList. However, the runtime behavior is still the same: IntList([1,2,3]) returns the list itself.

A common pitfall is that NewType does not create a new generic type. If you try to use IntList[int], you will get an error because IntList is not a generic class. This is a limitation: you cannot parameterize a NewType further. If you need a generic distinct type, you might need to use a class with TypeVar.

For most use cases, NewType with a concrete generic type is sufficient. It gives you type safety without runtime overhead.

Integrating NewType with Existing Type Hints

NewType works seamlessly with other type hint features. You can use it in function signatures, class attributes, and even with TypeVar for generic functions. For example:

from typing import NewType, TypeVar, List UserId = NewType('UserId', int) T = TypeVar('T') def first_element(items: List[T]) -> T: return items[0] user_id = UserId(1) users = [user_id] result = first_element(users) # type is UserId

Here, the type checker infers that result is of type UserId because the list contains UserId objects. This shows that NewType participates in type inference like any other type.

You can also use NewType with Optional and Union:

from typing import Optional, Union def find_user(user_id: Optional[UserId]) -> User: ...

This is useful when a function can accept None or a UserId.

One thing to keep in mind is that NewType is not a class, so you cannot use it with isinstance or issubclass. If you need to check the type at runtime, you must use a different approach, such as a custom class or a runtime validation library.

The Role of NewType in Large Codebases

In large codebases, NewType can significantly improve code maintainability by making the intent of each value explicit. When you see a function signature like def get_user(user_id: UserId) -> User, you immediately know that the argument is a user ID, not just any integer. This reduces the cognitive load and makes code reviews easier.

NewType also helps with refactoring. If you decide to change the underlying type of a UserId from int to str, you only need to change the NewType definition and the places where you create UserId values. The type checker will then flag any code that still uses the old type, making the migration safer.

However, NewType is not a substitute for good naming conventions. You should still use descriptive variable names and function names. NewType adds a layer of type safety that complements naming, but it does not replace it.

In summary, NewType is a lightweight, effective way to add type distinction to your Python code. It is especially useful for domain-driven design where you want to model different concepts using the same underlying primitive type. By using NewType, you get the benefits of static type checking without any runtime cost.

python newtype: Practical Usage and Code Examples | RYUSLOG DEV