TypeVar vs NewType: detection and resolution in Python
Unexpected type‑check failures when refactoring data models in microservice APIs usually appear in codebases that mix typing.TypeVar and typing.NewType, where NewType is only a static alias and disappears at runtime. This leads mypy to report incompatible types, silently breaking contract validation in production pipelines.
# Example showing the issue
from typing import NewType, TypeVar
UserId = NewType('UserId', int)
T = TypeVar('T')
def get_id(value: T) -> UserId:
return value # type: ignore
uid = get_id(42)
print('uid:', uid, 'type:', type(uid))
# Output: uid: 42 type: <class 'int'>
# mypy error: Incompatible return value type (got "int", expected "UserId")
NewType creates a distinct type only for static type checkers; at runtime it is just the underlying type. TypeVar represents a generic placeholder that can be bound to any type, but returning a plain int where a NewType is expected violates the static contract. This behavior follows PEP 484 and the typing module design. Related factors:
- NewType is erased at runtime
- TypeVar does not enforce NewType bounds automatically
- Missing explicit cast to NewType
To diagnose this in your code:
# Detect functions returning a NewType annotation but yielding the raw base type
import inspect, typing
def check_newtype_returns(func):
hints = typing.get_type_hints(func)
ret = hints.get('return')
if getattr(ret, '__supertype__', None): # NewType detection
src = inspect.getsource(func)
if ret.__supertype__.__name__ in src and 'return' in src and ret.__supertype__.__name__ not in src.split('return')[1]:
print(f'Potential NewType misuse in {func.__name__}')
check_newtype_returns(get_id)
Fixing the Issue
Quick Fix (1‑Liner)
uid = UserId(42) # direct cast to the NewType
When to use: Debugging or one‑off scripts where you just need the correct static type. Trade‑off: Skips generic handling; you lose the flexibility of a TypeVar.
Best Practice Solution (Production‑Ready)
from typing import NewType, TypeVar, Callable, cast
UserId = NewType('UserId', int)
T = TypeVar('T', bound=int)
def get_id(value: T) -> UserId:
# Explicit cast makes the intent clear to both runtime and type checkers
return cast(UserId, value)
# Optional runtime guard (helps catch accidental misuse)
def get_id_guarded(value: int) -> UserId:
if not isinstance(value, int):
raise TypeError('UserId must be an int')
return UserId(value)
uid = get_id(42)
assert isinstance(uid, int) # true at runtime
When to use: Production code, shared libraries, and API contracts. Why better: The cast (or explicit NewType call) satisfies static checkers, preserves generic flexibility, and optional runtime guard protects against accidental wrong types.
What Doesn’t Work
❌ Calling isinstance(value, UserId): always False because NewType is just int at runtime
❌ Using value as UserId without casting: bypasses static checks and may hide bugs
❌ Replacing NewType with a simple alias: loses the static distinction that mypy relies on
- Returning raw values for a NewType annotation without casting
- Using TypeVar without bounding it to the NewType’s base type
- Relying on isinstance() with a NewType, which always fails at runtime
When NOT to optimize
- Prototype scripts: Small one‑off utilities where type safety adds little value.
- Dynamic data pipelines: When data is already validated upstream and NewType adds no extra guarantee.
- Performance‑critical loops: Casting NewType repeatedly can add negligible overhead in tight loops.
- Legacy codebases: Introducing NewType may require extensive refactoring with minimal benefit.
Frequently Asked Questions
Q: Is this a typing bug in Python?
No. The distinction between NewType and TypeVar follows the design of PEP 484 and is intentional.
Q: Why does mypy complain about returning int from a NewType function?
Because NewType creates a separate static type; returning the raw int violates the declared return type.
Q: Can I use NewType with generics?
Yes, but you must bind a TypeVar to the NewType’s underlying type and cast appropriately.
Q: Do I need runtime checks for NewType?
Only if you want to enforce the contract at runtime; otherwise NewType is erased to its base type.
Understanding the split between static NewType aliases and runtime values prevents subtle type‑checker failures in large codebases. By casting explicitly or adding guarded helpers, you keep both static guarantees and runtime safety, ensuring your API contracts stay robust as they evolve.