Type Narrowing in Python — Deep Dive
Technical perspective
Type narrowing is the mechanism by which static type checkers refine the type of a variable within a specific code block based on runtime checks. While the concept sounds simple, the implementation details — what narrows, what doesn’t, and how different checkers interpret the same code — have significant practical consequences for large Python codebases.
Control flow analysis in depth
Type checkers like mypy, Pyright, and pytype build a control flow graph (CFG) during analysis. At each node in the graph, every variable has a type state — the set of types it could be at that point. Narrowing reduces this set.
What triggers narrowing
| Check | Narrows? | Notes |
|---|---|---|
x is None | ✅ | Narrows to None in true branch, removes None in false |
x is not None | ✅ | Inverse of above |
isinstance(x, T) | ✅ | Narrows to T (respects inheritance) |
type(x) is T | ✅ (pyright), ⚠️ (mypy) | mypy support varies; doesn’t handle subclasses |
callable(x) | ✅ | Narrows to callable types |
x (truthy check) | ✅ | Removes None, 0, "", etc. from union |
hasattr(x, "attr") | ⚠️ | Pyright narrows; mypy mostly doesn’t |
x in collection | ❌ | No narrowing in current implementations |
Truthy narrowing subtleties
def process(value: str | None) -> str:
if value:
return value.upper() # Narrowed to str
return "default"
This works, but it also excludes the empty string "". If empty strings are valid, use if value is not None: instead. This distinction catches many real bugs.
TypeGuard vs TypeIs (PEP 742)
Python 3.10 introduced TypeGuard (PEP 647), and Python 3.13 adds TypeIs (PEP 742). They solve the same problem — custom narrowing functions — but with different semantics.
TypeGuard: one-directional narrowing
from typing import TypeGuard
def is_str_list(val: list[int | str]) -> TypeGuard[list[str]]:
return all(isinstance(v, str) for v in val)
def demo(data: list[int | str]) -> None:
if is_str_list(data):
reveal_type(data) # list[str]
else:
reveal_type(data) # list[int | str] — NOT narrowed
TypeGuard only narrows the true branch. The else branch retains the original type because the checker can’t infer what failing the guard means.
TypeIs: bidirectional narrowing
from typing import TypeIs
def is_str(val: int | str) -> TypeIs[str]:
return isinstance(val, str)
def demo(val: int | str) -> None:
if is_str(val):
reveal_type(val) # str
else:
reveal_type(val) # int — narrowed in both branches!
TypeIs tells the checker: “if this returns False, the value is whatever remains after removing the checked type.” This requires that the narrowed type is a proper subtype of the input — a constraint TypeGuard doesn’t enforce.
When to use which
TypeIswhen checking a straightforward type membership (likeisinstancebut custom).TypeGuardwhen the narrowed type isn’t a strict subtype of the input (e.g., narrowinglist[object]tolist[str], which is a covariance trick that violates strict subtyping).
Discriminated unions with Literal types
A discriminated union uses a shared field with literal types to enable narrowing:
from dataclasses import dataclass
from typing import Literal, Union
@dataclass
class Circle:
kind: Literal["circle"] = "circle"
radius: float = 0.0
@dataclass
class Rectangle:
kind: Literal["rect"] = "rect"
width: float = 0.0
height: float = 0.0
Shape = Union[Circle, Rectangle]
def area(shape: Shape) -> float:
match shape.kind:
case "circle":
# Narrowed to Circle
return 3.14159 * shape.radius ** 2
case "rect":
# Narrowed to Rectangle
return shape.width * shape.height
Pyright handles discriminated unions well. Mypy requires the --enable-incomplete-feature=TypeVarTuple flag for some edge cases, but basic discriminated unions work in both.
This pattern is heavily used in event-driven systems where messages have a type field — narrowing ensures each handler accesses only the fields relevant to its message type.
Narrowing in exception handling
Type checkers narrow exception types in except blocks:
try:
result = int(user_input)
except ValueError as e:
# e is narrowed to ValueError
print(f"Bad input: {e}")
except (TypeError, OverflowError) as e:
# e is narrowed to TypeError | OverflowError
print(f"Type problem: {e}")
With Python 3.11+ ExceptionGroup, narrowing works in except* blocks too, but checker support is still maturing.
Advanced pattern: narrowing with Protocols
Protocols and isinstance checks don’t mix by default — Protocols are structural, not nominal. However, runtime-checkable protocols do narrow:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...
def render(obj: object) -> None:
if isinstance(obj, Drawable):
obj.draw() # Narrowed to Drawable
The @runtime_checkable decorator makes isinstance work at runtime, and checkers recognise the narrowing. Without it, isinstance(obj, Drawable) raises TypeError.
Pitfalls and edge cases
Narrowing doesn’t survive reassignment
def example(x: str | None) -> None:
if x is not None:
x = some_function() # x is now the return type of some_function
x.upper() # May fail — narrowing was reset
Narrowing in closures is fragile
def outer(x: str | None) -> None:
if x is not None:
def inner() -> str:
return x.upper() # mypy may complain — x could be reassigned
Mypy is conservative here because x could theoretically be reassigned between the check and the closure call. Pyright is more lenient and tracks final assignments.
Container element narrowing doesn’t work
items: list[str | None] = ["a", None, "b"]
items = [x for x in items if x is not None]
# mypy still sees list[str | None] — it doesn't narrow list elements
Use a TypeGuard function or explicitly annotate: items: list[str] = [x for x in items if x is not None].
Performance considerations
Type narrowing is purely a static analysis concept with zero runtime cost. The checks you write (isinstance, is None) have their normal runtime cost, but the narrowing itself — the checker’s refined understanding — adds nothing to execution time.
The real “performance” concern is developer time. A well-narrowed codebase eliminates entire classes of AttributeError and TypeError at development time. Teams at Dropbox reported that adding comprehensive type narrowing to their Python codebase (over 4 million lines) caught hundreds of latent bugs before they reached production.
Checker comparison
| Feature | mypy | Pyright | pytype |
|---|---|---|---|
| isinstance narrowing | ✅ | ✅ | ✅ |
| TypeGuard | ✅ | ✅ | ❌ |
| TypeIs (PEP 742) | ✅ (1.9+) | ✅ | ❌ |
| Discriminated unions | ⚠️ | ✅ | ❌ |
| hasattr narrowing | ❌ | ✅ | ❌ |
| Truthy narrowing | ✅ | ✅ | ⚠️ |
| Closure narrowing | Conservative | Smart | Conservative |
Pyright generally has the most sophisticated narrowing, which is why VS Code’s Pylance extension (powered by Pyright) often shows fewer false positives than mypy.
The one thing to remember: Effective type narrowing combines simple runtime checks (isinstance, is None) with type system features (TypeGuard, TypeIs, discriminated unions) to eliminate false positives and catch real bugs at development time — not at 3 AM in production.
See Also
- Ci Cd Why big apps can ship updates every day without turning your phone into a glitchy mess — CI/CD is the behind-the-scenes quality gate and delivery truck.
- Containerization Why does software that works on your computer break on everyone else's? Containers fix that — and they're why Netflix can deploy 100 updates a day without the site going down.
- Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.
- Python 311 New Features Python 3.11 made everything faster, error messages smarter, and let you catch several mistakes at once instead of stopping at the first one.
- Python 312 New Features Python 3.12 made type hints shorter, f-strings more powerful, and started preparing Python's engine for a world without the GIL.