← Home ← Codex ← DEBT
Browse by Category
+ added · updated 7d
← Back to glossary

Type Narrowing

Python Python 3.10+ Intermediate
debt(d5/e3/b3/t7)
d5 Detectability Operational debt — how invisible misuse is to your safety net

Closest to 'specialist tool catches it' (d5), mypy and pyright (from detection_hints.tools) flag the failed narrowing as a type error, but only when the team runs a strict type checker; without it the wide type silently persists.

e3 Effort Remediation debt — work required to fix once spotted

Closest to 'simple parameterised fix' (e3), quick_fix is to replace bool-returning checks with TypeGuard/TypeIs annotations or swap cast() for isinstance/None checks — a small localised pattern replacement within the helper, not a one-line swap nor cross-cutting.

b3 Burden Structural debt — long-term weight of choosing wrong

Closest to 'localised tax' (b3), applies to web/cli/library contexts but a narrowing helper or guard is typically a localised utility; the choice taxes the call sites that depend on it without reshaping the whole system.

t7 Trap Cognitive debt — how counter-intuitive correct behaviour is

Closest to 'serious trap' (t7), misconception states a plain bool-returning helper does NOT narrow — the obvious assumption contradicts how isinstance/TypeGuard behave, and narrowing silently widening back after reassignment or method calls compounds the surprise.

About DEBT scoring →

Also Known As

narrowing control flow analysis type guard TypeGuard

TL;DR

Static type checkers refine a variable's type within a branch based on runtime checks like isinstance, None comparisons, and literal guards.

Explanation

Type narrowing is how static type checkers (mypy, Pyright) reduce a broad declared type to a more specific one inside a conditional branch, using control flow analysis. If a variable is typed Optional[str] (i.e. str | None), checking if x is not None narrows x to str inside that block, letting you call string methods without a checker error. Narrowing is triggered by a fixed set of recognized patterns: isinstance(x, T), is None / is not None, type(x) is T, equality against Literal values, assert statements, truthiness checks, and bool guards on TypedDict keys.

User-defined narrowing is possible with type guards. A function annotated to return TypeGuard[T] (PEP 647) tells the checker that a True return means the argument is T. Python 3.13 adds TypeIs (PEP 742), which narrows in both branches and is usually the better choice because it also refines the negative case. Without these, a helper like is_valid(x) returning plain bool does not narrow anything - the checker still sees the original wide type after the call.

Narrowing also flows through early returns and raises. After `if x is None: raise ValueError`, the checker knows x is non-None for the rest of the function. assert isinstance(x, Foo) narrows from that line onward and doubles as a runtime guard. match statements narrow on case patterns including class patterns and literal patterns.

Narrowing has limits. It does not survive across function calls that could mutate state, and it is invalidated by reassignment. Accessing an attribute on a narrowed instance variable can lose narrowing if the checker cannot prove the attribute is stable - a common source of confusion is narrowing self.value then having it widen again after any method call. Local variable narrowing is reliable; narrowing through complex expressions or container elements often is not. Understanding what patterns the checker recognizes is the difference between clean type-checked code and a sea of cast() calls or type: ignore comments.

Common Misconception

A helper function returning bool will tell mypy that a variable is a specific type after the check. It will not - only isinstance, is None, literal comparisons, and functions annotated with TypeGuard or TypeIs actually narrow the type; a plain bool return leaves the original wide type in place.

Why It Matters

Correct narrowing eliminates spurious type errors and unnecessary cast() calls, keeping a strict type-checking setup usable; misunderstanding it leads teams to disable checks or scatter type: ignore comments that hide real bugs.

Common Mistakes

  • Writing a validation helper that returns bool and expecting the type to narrow after calling it - use TypeGuard or TypeIs instead.
  • Reusing cast() to silence the checker instead of adding an isinstance or None check that narrows safely.
  • Assuming narrowing on an instance attribute (self.value) survives a method call - it widens back to the declared type.
  • Using TypeGuard where TypeIs would be better, missing narrowing in the negative branch (Python 3.13+).
  • Reassigning a narrowed variable inside the branch, which silently widens it back to the declared type.

Avoid When

  • The codebase has no static type checker configured - narrowing is purely a checker concern with no runtime effect.
  • A simple inline isinstance or None check is clear enough that adding a TypeGuard helper just adds indirection.
  • Targeting Python below 3.10 where TypeGuard requires typing_extensions and TypeIs is unavailable.

When To Use

  • You have a validation predicate reused in many branches and want callers to get narrowing automatically.
  • Strict mypy or Pyright reports errors after a custom check because the type was not narrowed.
  • You are tempted to write cast() and want a runtime-safe alternative that the checker can verify.
  • Refining union types in conditional branches before accessing type-specific attributes or methods.

Code Examples

✗ Vulnerable
from typing import Optional

def is_nonempty(s: Optional[str]) -> bool:
    return s is not None and len(s) > 0

def process(value: Optional[str]) -> int:
    if is_nonempty(value):
        # mypy still sees Optional[str] here - bool return does not narrow
        return len(value)  # error: value could be None
    return 0

def handle(obj: object) -> str:
    # cast silences the checker but proves nothing at runtime
    from typing import cast
    return cast(str, obj).upper()
✓ Fixed
from typing import Optional
from typing_extensions import TypeIs

def is_nonempty(s: Optional[str]) -> TypeIs[str]:
    return s is not None and len(s) > 0

def process(value: Optional[str]) -> int:
    if is_nonempty(value):
        return len(value)  # narrowed to str - no error
    return 0

def handle(obj: object) -> str:
    if isinstance(obj, str):
        return obj.upper()  # narrowed to str inside the branch
    raise TypeError("expected str")

def parse(value: Optional[int]) -> int:
    if value is None:
        raise ValueError("missing")
    return value + 1  # narrowed to int after the early raise

Added 22 Jun 2026
Views 6
Rate this term
No ratings yet
🤖 AI Guestbook educational data only
| |
Last 30 days
0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 0 pings S 0 pings M 0 pings T 0 pings W 0 pings T 0 pings F 0 pings S 0 pings S 2 pings M 5 pings T 0 pings W
No pings yet today
Google 3 ChatGPT 1 Perplexity 1
ChatGPT 3 Google 3 Perplexity 1
crawler 6 crawler_json 1
DEV INTEL Tools & Severity
🟡 Medium ⚙ Fix effort: Low
⚡ Quick Fix
Replace bool-returning type checks with TypeIs (3.13+) or TypeGuard annotations, and prefer isinstance/None checks over cast() so the checker narrows safely.
📦 Applies To
python 3.10 web cli library mypy pyright
🔗 Prerequisites
🔍 Detection Hints
def is_\w+\([^)]*:\s*Optional\[[^\]]+\][^)]*\)\s*->\s*bool:|cast\([^,]+,
Auto-detectable: ✓ Yes mypy pyright
⚠ Related Problems
🤖 AI Agent
Confidence: Medium False Positives: High ✗ Manual fix Fix: Medium Context: Function


✓ schema.org compliant