skip to content

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

level: seniorimportance: should knowfreq 20%

answer

  1. Most checks need no predicate at all
  2. Prefer parsing over asserting where possible
  3. Predicates must stay pure and cheap
  4. Validate once at the decoding boundary
  5. An unverified assertion rots silently

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.

solid answer

~40 s

Checkers already narrow on `isinstance`, `is None`, `assert` and early returns, so wrapping any of those in a predicate adds a call, a name and an unverified assertion for no gain. A predicate earns its keep when the check is genuinely non-trivial or repeated: validating a decoded payload before treating it as a structured graph, or a multi-condition test duplicated across modules. Treat the body as you would `typing.cast` — it is a soundness hole you own, so keep it pure, total and cheap, and cover both outcomes with tests. Two production failures dominate: doing I/O inside a predicate, which turns a type question into a resource left unclosed, and calling an O(n) predicate inside a loop instead of validating once at the boundary and passing the narrowed value inward.

code

python · 16 lines
python
from collections.abc import Mapping
from typing import TypeIs

def is_service_graph(value: object) -> TypeIs[Mapping[str, list[str]]]:
    return (
        isinstance(value, Mapping)
        and all(isinstance(k, str) for k in value)
        and all(
            isinstance(v, list) and all(isinstance(s, str) for s in v)
            for v in value.values()
        )
    )

payload: object = {"checkout": ["cart", "pricing"], "cart": []}
if is_service_graph(payload):
    print(len(payload), "services in the dependency graph")

go deeper

for a junior

Know the baseline: isinstance, is None and assert already narrow for the checker, so most code needs no custom predicate. Recognise a wrapper that adds nothing.

for a middle

Explain what a predicate buys and costs: it communicates a conclusion the checker cannot reach on its own, at the price of an assertion nothing verifies and a runtime walk nothing measures.

for a senior

Show the production judgement — validate once at the decoding boundary, keep predicates pure and total, test both branches, and recognise I/O or hot-loop use as defects rather than style issues.

for a principal

Own the strategy: decide whether untrusted input is narrowed by hand-written predicates or parsed into real types at the edge, and set the standard that keeps a growing pile of unverified assertions from eroding the value of type checking.

## The default answer is: do not write one A type checker narrows on plenty of things without help — `isinstance`, `issubclass`, `type(x) is C`, `x is None`, truthiness tests, `assert`, early returns, and match patterns. Any predicate you write to wrap one of those replaces a construct the checker *understands* with an assertion it merely *believes*. The trade is strictly negative: you gain a function call and a name, and you lose verification. So the question is not how to write a predicate but when the alternative is worse. ## The three cases that justify one **The conclusion cannot be expressed inline.** Some properties have no class to test: a decoded `dict[str, object]` that matches a `TypedDict` shape, a `list[object]` proved to hold only strings, a fixed-length tuple, a union member selected by a discriminator field. `isinstance` cannot say any of it, so a predicate is the only channel to the checker. **The check is non-trivial and repeated.** A multi-clause test duplicated in several modules is a maintenance problem regardless of typing. Naming it once gives the check a place to live, a docstring and a unit test — and the annotation is then a bonus, not the motivation. **The check sits on a decoding boundary.** A feature-flag service that loads a service dependency graph from JSON has exactly one place where `object` becomes structured data. A predicate there converts an unavoidable runtime validation into a typed value for everything downstream: ```python from collections.abc import Mapping from typing import TypeIs def is_service_graph(value: object) -> TypeIs[Mapping[str, list[str]]]: return ( isinstance(value, Mapping) and all(isinstance(k, str) for k in value) and all( isinstance(v, list) and all(isinstance(s, str) for s in v) for v in value.values() ) ) ``` Note that `TypeIs` is legal here because the parameter is `object`, to which anything is assignable. Had the parameter been `dict[str, object]` and the target a `TypedDict`, invariance would have forced `TypeGuard` instead. ## What goes wrong in production **The predicate is trusted but not tested.** Nothing verifies the body, so the failure mode is a silent lie that surfaces far downstream as an `AttributeError` or a `KeyError` on a value the checker swore was safe. Cover both outcomes: values that must pass, and near-misses that must fail — an empty container, a nested wrong type, a missing key. This matters more for `TypeIs` than for `TypeGuard`, because with `TypeIs` a wrong False also misleads the else branch. **The predicate does I/O.** A check that opens a file, a socket or a database handle to decide the answer is not a type predicate; it is a side effect wearing one's clothes. Called in a condition it looks free, so it gets called in loops, in comprehensions and inside exception handlers, and the handle it forgot to close leaks until the process runs out of descriptors. Keep predicates pure: no I/O, no mutation, no logging with side effects, and no consuming an iterator you were handed, which quietly empties the caller's data. **The predicate is O(n) and is called O(n) times.** Deep validation of a dependency graph across, say, seventeen services is cheap once and painful in a loop. Validate at the boundary, bind the narrowed value to a name, and pass that name inward; do not re-ask the same question in each function that receives the value. **The predicate rots.** When the asserted shape gains a field, the predicate is the one place a checker will not remind you to update — it will simply keep asserting the old, now-incomplete claim. Keep the predicate next to the type it describes, and treat editing that type as a prompt to review it. ## The alternatives worth weighing Sometimes the right answer is not a predicate at all. Parsing into a real class or a dataclass at the boundary gives the checker a genuine type without any assertion; a schema-validation library does the same and gives better errors. A `typing.cast` is the honest choice when you truly have out-of-band knowledge and no runtime check is possible — at least it does not pretend to have verified anything. And for values whose invalid states should abort, `assert isinstance(...)` narrows without any new construct. Reserve hand-written predicates for the middle ground: a real runtime check whose conclusion the type system cannot otherwise learn. ## Versions `typing.TypeGuard` requires Python 3.10, `typing.TypeIs` requires 3.13; on 3.12 and older only the one-way form is available in the standard library. Neither construct affects runtime behaviour in any version, including 3.14.

  • A predicate that walks a decoded payload is being called inside a loop over its own entries. What do you change?
    Hoist it. Call the predicate once where the payload enters the process, bind the narrowed value to a name, and pass that name into the loop. The check is linear in the structure, so repeating it per entry turns validation into quadratic work while adding no safety — the value cannot change type between iterations unless something mutates it, which is a separate bug.
  • How do you keep a hand-written predicate honest as the type it asserts evolves?
    Keep it beside the type it describes so a change to one is visible next to the other, and unit-test both outcomes with realistic values and near-misses. Where the shape is large, prefer parsing into a class or validating with a schema layer at the boundary, so the checker gets a real type instead of an assertion nothing verifies.
  • Why should a type predicate never perform I/O?
    Because it is called in conditions, where it looks free, so it ends up inside loops, comprehensions and exception handlers. Any handle it opens and fails to close leaks until the process exhausts descriptors, and the cost is invisible to reviewers reading an if statement. A predicate should be pure, total and cheap; anything needing I/O belongs in an explicit function whose call site shows what it costs.
  • When is typing.cast the better tool than a predicate?
    When there is genuinely nothing to check at runtime — knowledge that comes from outside the program, such as a registry contract or a call that already validated. A cast is honest about asserting without verifying, whereas a predicate that returns True unconditionally is a cast dressed up as a check, and a reader will assume validation happened.

saying these in an interview costs you the question

  • Wrapping a single isinstance call in a predicate
  • Believing a checker validates the predicate body
  • Opening files or sockets inside a type predicate
  • Re-running an O(n) predicate inside a hot loop
  • Consuming the caller's iterator while checking it
  • Never testing the False path of the predicate

context