skip to content

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