skip to content

Narrowing and Overloads

How a checker reads control flow to turn a wide type into a precise one, and how you describe a function whose return type depends on its arguments. Shows whether you work with a checker or fight it.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

12

How does an isinstance check narrow a `str | bytes` value for a type checker?

level: juniorimportance: must knowfreq 68%

answer

  1. Declared type versus what the branch proves
  2. The checker reads control flow
  3. Both branches change, not just one
  4. Subclass test, so bool passes for int
  5. Narrowing dies when the name is rebound

basics

~20 s

A static type checker follows control flow: inside if isinstance(chunk, bytes): the declared union str | bytes shrinks to bytes, and the other branch keeps only str. The narrowing lasts until the name is rebound.

solid answer

~40 s

Annotating a parameter `str | bytes` gives it that *declared* type everywhere, but a checker computes a *narrowed* type per branch by reading the control flow. `isinstance(chunk, bytes)` proves `bytes` in the positive branch, and because `str` and `bytes` are unrelated classes the negative branch is narrowed to `str` — so `.decode()` type-checks on one side and `.encode()` on the other. The second argument may be a class, a tuple of classes, or, since Python 3.10, a `str | bytes` union object. Remember that `isinstance` is a *subclass* test: narrowing to `int` still admits `bool`. Narrowing is attached to the expression and is thrown away when the name is reassigned, so the durable fix in an ingest path is to decode once at the boundary and keep the interior `str`-only.

code

python · 10 lines
python
def as_text(chunk: str | bytes) -> str:
    if isinstance(chunk, bytes):
        # narrowed to bytes here: .decode() type-checks
        return chunk.decode("utf-8", errors="replace")
    # narrowed to str here: .decode() would be an error
    return chunk


print(as_text(b"\xff parse error"))
print(as_text("already text"))

go deeper

for a junior

Be ready to say what a checker infers in each branch of an isinstance test, and to show the two-branch shape that keeps .decode() and .encode() on the right sides of a str | bytes value.

for a middle

Explain the mechanics: the negative branch is the union minus the tested class, subtraction removes nothing when the class is not a supertype, and isinstance narrows to a class or any subclass.

for a senior

Show judgement about where narrowing belongs — normalising bytes to text once at the ingest boundary beats branching on the union in twenty downstream functions, and narrowed attributes should be bound to locals.

for a principal

Own the argument that a union surviving deep into a codebase is a design signal: decide where the decode boundary sits, what the codec and error policy are, and which modules are allowed to see raw bytes at all.

## Declared type versus narrowed type When you write `def as_text(chunk: str | bytes) -> str:`, `str | bytes` is the **declared** type of `chunk` — it is what the parameter may hold on entry. A static type checker does not stop there: it walks the function's control-flow graph and, at every point, computes the **narrowed** type, the smallest type it can prove given the branches taken to reach that point. Narrowing is entirely a compile-time artefact. Nothing about the object changes at runtime, no cast happens, and no annotation is consulted while the program runs. ## What each branch gets `isinstance` is one of the handful of checks every checker understands. - In the **positive branch** of `if isinstance(chunk, bytes):`, the union is filtered to the members that could be instances of `bytes`, leaving `bytes`. - In the **negative branch** the checker subtracts: a member is removed only when the tested class is a supertype of it. `str` is not a `bytes` subclass, so the else branch is exactly `str`. Subtraction does not always remove something. `isinstance(x, object)` removes nothing, and testing an `int`-typed value against `bool` leaves `int` in the negative branch, because a non-`bool` `int` is still possible there. Narrowing that *fails* to shrink anything is a common source of "why is this still Optional?" confusion. ## isinstance is a subclass test `isinstance(True, int)` is `True`, because `bool` subclasses `int`; narrowing to `int` therefore still admits `bool`. If you need the exact class — for example to reject `bool` where only a real integer makes sense — `type(x) is int` is the check, and checkers narrow it to exactly that class. - **Abstract base classes** work too: `isinstance(x, collections.abc.Sequence)` succeeds for registered virtual subclasses, and checkers narrow accordingly. - A `typing.Protocol` may only be used with `isinstance` when it is decorated `@runtime_checkable`, and then the runtime check only verifies that the named members exist, not that their signatures match — the static narrowing is stricter than the runtime test. ## Forms of the second argument - A tuple such as `isinstance(x, (str, bytes))` narrows to the union of those classes. - Since Python 3.10 (PEP 604) a union object works directly: `isinstance(x, str | bytes)`. Both are equivalent for narrowing purposes; the tuple form is what you need if you must still run on 3.9. ## How long narrowing lasts The narrowed type is bound to an *expression*, and it survives only while the checker can be sure the expression's value has not changed. - Rebinding the name discards it; so does the top of a loop, where the checker must widen back to whatever the variable can be on any iteration. - Narrowing a simple local name is the reliable case. - Narrowing an attribute like `self.chunk` or a subscript like `frame[0]` is supported only in limited ways and varies between checkers, because another call could mutate the object in between. The idiomatic workaround is to bind the value to a local first (`chunk = self.chunk`) and narrow that. ## Why it matters in an ingest path A log-ingest pipeline reads frames from a socket or a file opened in binary mode and gets `bytes`; configuration and JSON payloads arrive as `str`. Calling `.decode()` on a `str` or `.encode()` on `bytes` is the classic **encoding mismatch**, and at runtime both are an `AttributeError` raised somewhere far from the mistake. Declaring the union honestly and branching on `isinstance` lets the checker catch that before deploy. The better shape still is to **normalise at the boundary** — decode each frame once with an explicit codec and an explicit `errors=` policy — so that everything downstream is `str` and no narrowing is needed at all. A union type that persists deep into a codebase is usually a design smell rather than a typing problem. **Other checks in the same family.** `is None` comparisons, `assert` statements, early `return`/`raise`, and `match`/`case` class patterns all narrow through the same machinery. They differ in what they prove, not in how the checker records it. ## The class-object cousin: `issubclass` `isinstance` narrows a value; `issubclass` narrows a *class object*. If a registry holds handlers typed `type[Handler]`, then inside `if issubclass(cls, TextHandler):` the checker narrows `cls` to `type[TextHandler]`, so class-level attributes and the constructor signature of the subclass become visible. The two are easy to confuse in a plugin loader, and confusing them is not a quiet mistake: `issubclass` requires a class as its first argument and raises `TypeError` when handed an instance, so the wrong one fails immediately rather than narrowing incorrectly. **A quick way to see what the checker sees.** `typing.reveal_type(x)` is understood by type checkers as a request to print the narrowed type at that point, and `reveal_type` also exists at runtime in `typing` since 3.11, where it prints the runtime type and returns its argument. Dropping one into each branch is the fastest way to settle an argument about what a given check proved.

  • How does narrowing with `type(x) is str` differ from `isinstance(x, str)`?
    `isinstance` is a subclass test, so it narrows to "this class or any subclass" — narrowing to `int` still admits `bool`. `type(x) is str` proves the exact class, so a checker narrows it to that class alone and leaves subclasses in the other branch. The identity form also ignores abstract base classes and virtual subclass registration, so it will reject values `isinstance` would accept.
  • Why does a checker sometimes forget an isinstance narrowing on `self.chunk`?
    Narrowing is attached to an expression and only holds while the checker can prove the value has not changed. A plain local name is easy; an attribute or a subscript is not, because an intervening call could rebind or mutate it. Checkers differ in how far they carry those narrowings, so the portable habit is to assign to a local variable and narrow that.
  • Can `isinstance` be used against a `typing.Protocol`?
    Only if the protocol is decorated `@runtime_checkable`, and even then the runtime test just checks that the named members exist — it does not verify signatures or return types. So the check can pass for an object the checker would reject statically. Treat it as a shallow duck-typing probe, not a proof of conformance.

Think of the annotation as the label on a mailbox and narrowing as opening it: once you have looked inside this particular branch, you know exactly what is there — until someone puts new mail in.

saying these in an interview costs you the question

  • Thinks isinstance converts or casts the object at runtime
  • Claims only the positive branch is narrowed
  • Assumes narrowing to int excludes bool values
  • Uses isinstance with any Protocol without runtime_checkable
  • Believes narrowing survives reassigning the name
  • Says isinstance(x, str | bytes) is invalid on Python 3.14

context

open as a page

Why does `if x:` narrow an `int | None` differently from `if x is not None:`?

level: middleimportance: must knowfreq 62%

basics

~20 s

x is not None splits the union exactly: int in the true branch, None in the false one. if x: only proves truthiness, so 0 takes the false branch and the checker still types that branch int | None.

open as a page

When is @typing.overload better than annotating one union return type?

level: middleimportance: must knowfreq 45%

basics

~20 s

Use @typing.overload when the return type depends on which arguments were passed. A single union return makes every caller narrow it; overloads give each call site the one exact type, and can make invalid argument combinations a checker error.

open as a page

How does `typing.TypeIs` narrowing differ from `typing.TypeGuard` in the else branch?

level: middleimportance: must knowfreq 35%

basics

~20 s

TypeIs narrows both branches: the value is the guarded type in the if branch and has that type subtracted in the else branch. TypeGuard narrows only the if branch and leaves the else branch at the declared type.

open as a page

In a module using @typing.overload, which def actually runs at call time?

level: juniorimportance: should knowfreq 30%

basics

~20 s

Only the final, undecorated def has a real body and runs. The @typing.overload stubs above it exist for the type checker, their ellipsis bodies are never executed, and a name that has only stubs raises NotImplementedError when called.

open as a page

What does a function annotated `-> typing.TypeGuard[list[str]]` return at runtime?

level: juniorimportance: should knowfreq 28%

basics

~10 s

A plain bool. TypeGuard[list[str]] is a static-only annotation: the function returns True or False, the interpreter enforces nothing, and a type checker simply trusts that boolean to narrow the argument inside the if branch.

open as a page

Why is `assert isinstance(...)` an unsafe way to narrow types in shipped code?

level: middleimportance: should knowfreq 38%

basics

~20 s

assert statements are compiled out when CPython runs with -O or with PYTHONOPTIMIZE set. The checker still trusts the narrowing, but the runtime guard has vanished, so a wrong type flows on and fails somewhere else entirely.

open as a page

Why must a plain bool @typing.overload stub come after the Literal[True] one?

level: middleimportance: should knowfreq 32%

basics

~20 s

A type checker tries overload stubs top to bottom and takes the first whose parameters accept the call. A plain bool stub accepts every call, so placing it first shadows the Literal[True] and Literal[False] stubs and hands every caller the wide union return.

open as a page

What problem does `typing.TypeGuard` solve that a plain `isinstance` check cannot?

level: middleimportance: should knowfreq 30%

basics

~20 s

isinstance can only test a runtime class, so it proves a value is a list but never a list of strings. A TypeGuard predicate runs that element check and hands the checker the parameterized conclusion.

open as a page

How does typing.assert_never turn a missed Enum member into a type error?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Once every member is handled, a checker narrows the fall-through branch to Never. typing.assert_never accepts only a Never argument, so adding a new member makes that call fail to type-check — the miss becomes a build error instead of a silent default.

open as a page

Your ETL export helper's @typing.overload stubs forbid a precision value, yet the nightly job hits it — why?

level: seniorimportance: should knowfreq 26%

basics

~20 s

Overloads are checked, never enforced. Nothing at runtime consults them, so a value arriving as a plain str from a job config, or through a call the checker never saw, reaches the implementation unchallenged. Only an explicit check inside the implementation can reject it.

open as a page

When is a custom `typing.TypeIs` predicate worth writing instead of an inline isinstance check?

level: seniorimportance: should knowfreq 20%

basics

~20 s

Only when the test is non-trivial, reused, and cannot be expressed inline — a decoded payload shape, a repeated multi-condition check. A predicate wrapping one isinstance call buys nothing and adds an assertion no checker verifies.

open as a page