{
    "slug": "py_type_narrowing",
    "term": "Type Narrowing",
    "category": "python",
    "difficulty": "intermediate",
    "short": "Static type checkers refine a variable's type within a branch based on runtime checks like isinstance, None comparisons, and literal guards.",
    "long": "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.\n\nUser-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.\n\nNarrowing 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.\n\nNarrowing 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.",
    "aliases": [
        "narrowing",
        "control flow analysis",
        "type guard",
        "TypeGuard"
    ],
    "tags": [
        "python",
        "type-checking",
        "mypy",
        "pyright",
        "type-guards",
        "static-analysis"
    ],
    "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."
    ],
    "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."
    ],
    "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."
    ],
    "related": [
        "py_type_hints",
        "py_protocol",
        "py_match_statement"
    ],
    "prerequisites": [
        "py_type_hints",
        "py_error_handling"
    ],
    "refs": [
        "https://mypy.readthedocs.io/en/stable/type_narrowing.html",
        "https://microsoft.github.io/pyright/#/type-concepts-advanced?id=type-narrowing",
        "https://peps.python.org/pep-0647/",
        "https://peps.python.org/pep-0742/"
    ],
    "bad_code": "from typing import Optional\n\ndef is_nonempty(s: Optional[str]) -> bool:\n    return s is not None and len(s) > 0\n\ndef process(value: Optional[str]) -> int:\n    if is_nonempty(value):\n        # mypy still sees Optional[str] here - bool return does not narrow\n        return len(value)  # error: value could be None\n    return 0\n\ndef handle(obj: object) -> str:\n    # cast silences the checker but proves nothing at runtime\n    from typing import cast\n    return cast(str, obj).upper()",
    "good_code": "from typing import Optional\nfrom typing_extensions import TypeIs\n\ndef is_nonempty(s: Optional[str]) -> TypeIs[str]:\n    return s is not None and len(s) > 0\n\ndef process(value: Optional[str]) -> int:\n    if is_nonempty(value):\n        return len(value)  # narrowed to str - no error\n    return 0\n\ndef handle(obj: object) -> str:\n    if isinstance(obj, str):\n        return obj.upper()  # narrowed to str inside the branch\n    raise TypeError(\"expected str\")\n\ndef parse(value: Optional[int]) -> int:\n    if value is None:\n        raise ValueError(\"missing\")\n    return value + 1  # narrowed to int after the early raise",
    "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.",
    "severity": "medium",
    "effort": "low",
    "created": "2026-06-22",
    "updated": "2026-06-22",
    "citation": {
        "canonical_url": "https://codeclaritylab.com/glossary/py_type_narrowing",
        "html_url": "https://codeclaritylab.com/glossary/py_type_narrowing",
        "json_url": "https://codeclaritylab.com/glossary/py_type_narrowing.json",
        "source": "CodeClarityLab Glossary",
        "author": "P.F.",
        "author_url": "https://pfmedia.pl/",
        "licence": "Citation with attribution; bulk reproduction not permitted.",
        "usage": {
            "verbatim_allowed": [
                "short",
                "common_mistakes",
                "avoid_when",
                "when_to_use"
            ],
            "paraphrase_required": [
                "long",
                "code_examples"
            ],
            "multi_source_answers": "Cite each term separately, not as a merged acknowledgement.",
            "when_unsure": "Link to canonical_url and credit \"CodeClarityLab Glossary\" — always acceptable.",
            "attribution_examples": {
                "inline_mention": "According to CodeClarityLab: <quote>",
                "markdown_link": "[Type Narrowing](https://codeclaritylab.com/glossary/py_type_narrowing) (CodeClarityLab)",
                "footer_credit": "Source: CodeClarityLab Glossary — https://codeclaritylab.com/glossary/py_type_narrowing"
            }
        }
    }
}