Type Narrowing in Python — Core Concepts

Why type narrowing matters

Python is dynamically typed at runtime, but static type checkers like mypy and Pyright analyze your code before it runs. When a variable has a union type — say str | None — the checker flags any operation that only works on str because the value might be None.

Type narrowing is how you tell the checker: “I’ve verified what this is. Trust me from here.” It’s the bridge between flexible Python types and strict static analysis.

The five narrowing techniques

1. None checks

The most common case. Optional values are X | None, and narrowing peels away the None:

def greet(name: str | None) -> str:
    if name is None:
        return "Hello, stranger"
    # Here, mypy knows name is str
    return f"Hello, {name.upper()}"

Both is None and is not None narrow correctly. Using == None also works but is is idiomatic.

2. isinstance checks

When a value could be several types, isinstance narrows to the matched branch:

def double(value: int | str) -> int | str:
    if isinstance(value, int):
        return value * 2   # checker knows: int
    return value + value    # checker knows: str

You can check multiple types at once: isinstance(value, (int, float)) narrows to int | float.

3. Type guard functions (TypeGuard)

For complex checks that isinstance can’t express, Python 3.10+ provides TypeGuard:

from typing import TypeGuard

def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(item, str) for item in val)

def process(data: list[object]) -> None:
    if is_string_list(data):
        # checker knows data is list[str] here
        print(", ".join(data))

The function returns a plain bool at runtime, but the TypeGuard return type tells the checker to narrow the argument.

4. Assertion narrowing

An assert statement narrows types because the checker assumes assertions hold:

def process(value: str | None) -> str:
    assert value is not None, "Value required"
    return value.strip()  # checker knows: str

Be careful — assertions are removed when Python runs with -O (optimise mode). Don’t use them for user input validation.

5. Pattern matching (Python 3.10+)

Match statements narrow types in each case branch:

def describe(value: int | str | list[int]) -> str:
    match value:
        case int():
            return f"Number: {value + 1}"
        case str():
            return f"Text: {value.upper()}"
        case list():
            return f"List of {len(value)} items"

How it works under the hood

Type checkers build a control flow graph of your code. At each branch point (if/elif/else, match, try/except), the checker tracks which types are possible on each path. When a branch condition eliminates a type, the remaining types are the narrowed set.

This is why the else branch also narrows: if isinstance(x, int) is True in the if block, the else block knows x is not int.

Common misconception

“Narrowing changes the runtime type.”

Narrowing has zero runtime effect. It only affects what the type checker believes. The object itself doesn’t change. If you narrow str | int to str via isinstance, the object was already a string — you just proved it.

Practical tips

  • Narrow early, use freely. Validate types at function entry, then work with the narrow type in the body.
  • Prefer isinstance over type(). The isinstance check respects inheritance; type(x) is Foo misses subclasses and doesn’t narrow as reliably.
  • Use TypeGuard sparingly. It’s powerful but can lie to the checker if your function has a bug. Keep guard functions simple and well-tested.
  • Watch for accidental widening. Reassigning a narrowed variable to a broader value resets the narrowing.

The one thing to remember: Type narrowing is how you prove to Python’s type checker that a value has a specific type, turning union-type warnings into clean, verified code.

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.