skip to content

How do you keep typing.Any from spreading through a partly typed module?

level: seniorimportance: should knowfreq 40%

answer

  1. One hole, and it is contagious
  2. Compatible in both directions, unlike everything else
  3. Four sources: untyped imports, parsing, missing returns, explicit
  4. Convert at the edge into a named shape
  5. object forces narrowing; reveal_type finds the leak

basics

~20 s

Convert unknown values into a named shape at the point they enter, annotate every return type so unknowns cannot flow outward, and prefer object over Any when you genuinely do not know, because object forces a narrowing check.

solid answer

~50 s

`Any` is compatible with everything in both directions, so wherever it lands, checking stops — and it travels: an `Any` value's attributes, items and call results are all `Any` too, which is how one untyped boundary can blank out a whole call chain. I contain it the way you contain untrusted input. First, identify the sources: untyped imports, `json.loads`, unannotated function returns, and explicit `Any` I wrote myself. Second, convert at the entry point — parse the incoming value into a `TypedDict` or a dataclass right there, so precisely one function sees the unknown. Third, prefer `object` to `Any` for a genuinely unknown value: `object` is not compatible in both directions, so the checker forces an `isinstance` check before use. When a type has already degraded, `typing.reveal_type` at a few points down the chain tells me exactly where precision was lost.

code

python · 19 lines
python
import json
from typing import TypedDict


class Record(TypedDict):
    accession: str
    year: int


def load_records(text: str) -> list[Record]:
    """The only function that touches the untyped blob."""
    raw = json.loads(text)  # Any stops here
    records: list[Record] = []
    for item in raw:
        records.append({"accession": str(item["accession"]), "year": int(item["year"])})
    return records


print(load_records('[{"accession": "A-1", "year": "1912"}]'))

go deeper

for a junior

Know what typing.Any means: it turns checking off for that value. If you see it in code you are annotating, treat it as a placeholder someone left behind rather than a real description of the data.

for a middle

Explain the contagion mechanically — attributes, items and call results of an Any value are Any too — and name the usual sources: untyped imports, json.loads, functions with no return annotation, and explicit Any.

for a senior

Show the containment design: one narrow entry point that converts the unknown into a named shape, object where the type is genuinely unknown, and typing.reveal_type used to locate where precision was lost in an existing chain.

for a principal

Own the policy: whether explicit Any needs a justification in review, how you would detect decorative annotations across many teams, and what you accept permanently at boundaries you do not control.

## Why Any spreads `Any` is not "unknown". It is the type that is assignable *to* everything and *from* everything, which makes it the deliberate hole in a gradual type system: it exists so untyped and typed code can meet without the checker screaming. The consequence is that `Any` is contagious in a way no other type is. If `record` is `Any`, then `record["year"]` is `Any`, `record.year` is `Any`, `record.parse()` is `Any`, and anything you assign them to is `Any`. Nothing is reported along the way, because every operation on `Any` is permitted. A module can be 90% annotated and still be effectively unchecked because one value entering at the top is `Any` and everything downstream inherits it. ## Where it comes from Four sources cover almost every real case: 1. **Untyped boundaries.** A dependency that ships no type information gives you `Any` for everything it returns. 2. **Dynamic parsing.** `json.loads` is annotated to return `Any` by design, because its result genuinely depends on the input bytes. Every codebase that reads JSON has this leak. 3. **Unannotated functions.** A function with no return annotation returns an unknown type; in many checkers that reaches callers as `Any`. 4. **Explicit `Any` you wrote.** Usually added early in a migration to silence something, and rarely revisited. Only the fourth is visible by grepping. The others are invisible in the source, which is why containment has to be a design habit rather than a code review keyword search. ## Containment: convert at the edge The pattern that works is the same one used for untrusted input: **a narrow entry point that turns the unknown into a known shape, and nothing downstream that touches the unknown at all.** Concretely, the function that calls `json.loads` should not return its result; it should build a `TypedDict` or a dataclass and return that. Then exactly one function in the module deals with `Any`, its body is short enough to read carefully, and everything after it is fully checked. This inverts the common instinct, which is to pass the parsed blob around and annotate the *consumers*. Consumers annotated against `Any` inputs give false confidence: the annotations are there, but nothing verifies that the values match them. ## Use `object` when you truly do not know `object` is the honest "I do not know what this is" type, and it behaves the opposite way to `Any`: everything is assignable to `object`, but `object` is assignable to almost nothing, so the checker refuses every operation until you narrow with an `isinstance` check or a comparison. That refusal is the feature. In a legacy migration, changing a parameter from `Any` to `object` is often a one-line change that immediately produces a list of every place the value is used unsafely — which is exactly the list you wanted. ## Finding a leak you already have When a type is more permissive than expected, `typing.reveal_type(value)` is the debugger. Placed at a point in the chain, the checker reports the type it has inferred there. Binary-search the chain: reveal near the boundary, reveal near the failure, and narrow down to the assignment where a precise type became `Any`. Since 3.11 `reveal_type` also exists at runtime — it prints the runtime type and returns the value — so an accidentally committed call does not break the program, though it should not survive review. `typing.assert_type` (also 3.11) is the assertion form: it fails the check if the inferred type is not the one you name, which is useful as a permanent guard at a boundary you have fought to make precise. ## The scenario worth telling On a museum-catalogue importer, the loader returned the result of `json.loads` directly and every module downstream annotated its parameters as a record type. The annotations were decorative: nothing checked them. The nightly six-hour run failed near the end with an `AttributeError` on a value that had always been a plain string. The fix was not more annotations, it was one: make the loader return a named record shape, and let the checker find the seventeen places that had been guessing.

  • What is the practical difference between annotating a parameter `Any` and annotating it `object`?
    `Any` is assignable in both directions, so every attribute access, subscript and call on the value is permitted and unchecked. `object` accepts any value but permits almost nothing without narrowing, so the checker reports each unsafe use until you add an `isinstance` check. `object` says 'unknown' honestly; `Any` says 'stop checking'.
  • A module is fully annotated but a shape bug still reaches production. What do you suspect first?
    An `Any` entering at a boundary and flowing into those annotated parameters, which makes the annotations decorative — nothing verifies them. I would place `typing.reveal_type` near the boundary and near the failure to find where precision was lost, then convert the value into a named shape at the point it enters rather than adding more annotations downstream.
  • How does `typing.assert_type` differ from `typing.reveal_type`?
    `reveal_type` is a diagnostic: the checker prints whatever type it inferred, and you read it. `assert_type` is a permanent assertion: you name the type you expect and the check fails if inference disagrees. Both arrived in 3.11. `assert_type` is worth leaving in place at a boundary whose precision you fought for, so a later change that reintroduces `Any` is caught.

saying these in an interview costs you the question

  • Thinks Any and object mean the same thing
  • Adds annotations downstream instead of converting at the boundary
  • Believes a value annotated Any is still checked
  • Says json.loads returns a typed dictionary
  • Uses Any as the default for anything inconvenient

context