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

CheckNarrows?Notes
x is NoneNarrows to None in true branch, removes None in false
x is not NoneInverse 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 collectionNo 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

  • TypeIs when checking a straightforward type membership (like isinstance but custom).
  • TypeGuard when the narrowed type isn’t a strict subtype of the input (e.g., narrowing list[object] to list[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

FeaturemypyPyrightpytype
isinstance narrowing
TypeGuard
TypeIs (PEP 742)✅ (1.9+)
Discriminated unions⚠️
hasattr narrowing
Truthy narrowing⚠️
Closure narrowingConservativeSmartConservative

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.

pythontypingintermediate

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.