In a nightly report generator, how does one `typing.Any` from a JSON boundary spread through the code below it?
answer
- silence, not an error message
- one untyped edge, whole chain
- operations on it yield it again
- json.loads is annotated to return Any
- validate into a declared shape at the edge
basics
~20 sAny is contagious: every attribute, call or subscript on an Any value yields Any again, so one untyped boundary such as json.loads silently switches checking off down the chain. Validate at the edge into a declared shape.
solid answer
~50 s`typing.Any` is bidirectionally compatible and, crucially, contagious: attribute access, subscription, a call or arithmetic on an `Any` value all produce `Any` again. `json.loads` is annotated to return `Any`, so a nightly report generator that reads its config that way hands `Any` to the helper it calls, which returns `Any` to *its* caller, and checking stops silently along that whole path — no error is ever reported, which is exactly why nobody notices. The other sources are unannotated function signatures, `**kwargs: Any`, `dict[str, Any]` and dependencies shipped without type stubs. Contain it at the edge: validate the parsed data once and return a declared shape such as a `TypedDict` or a dataclass; prefer `object` to `Any` where you truly accept anything, so misuse becomes a visible error instead of silence; and enable the checker's strict settings for returning `Any` in CI, so one module cannot quietly de-type shared helpers.
code
python · 14 linesimport json
from typing import Any
def load_config(text: str) -> Any: # json.loads is annotated to return Any
return json.loads(text)
def retention_days(text: str) -> int:
config = load_config(text) # config is Any
return config["retention"]["days"] # still Any, accepted as int unchecked
print(retention_days('{"retention": {"days": 30}}'))go deeper
Recall the core fact: anything you do to an Any value gives you another Any, so it spreads down the call chain. Know that json.loads returns Any and that this is why parsed data needs checking before use.
Explain the mechanics of propagation — subscription, attribute access and calls on Any all yield Any, and Any is assignable to any declared return type. Be able to list the common sources: unannotated functions, dict[str, Any], untyped imports.
Demonstrate the production judgement: how you detect that checking has silently stopped, why the damage usually surfaces on an error path that runs for the first time in production, and how you kill the Any inside one validating boundary function.
Own the rollout strategy: which strict settings become a CI gate, how you ratchet them in across many contributors without a freeze, and where you accept unchecked surface because runtime validation, not the checker, carries the guarantee.
The failure this question describes is not a type error. It is the *absence* of type errors in code that has quietly stopped being checked, which is a far harder thing to notice in review. ## The propagation rule `typing.Any` is compatible with every type in both directions, and every operation performed on an `Any` value yields `Any`. Attribute access, subscription, a call, arithmetic, iteration — all of them take `Any` in and give `Any` back. So `Any` does not sit still where you put it; it flows along every expression derived from the value, and out through the return type of any function that returns such an expression. Concretely, in a nightly report generator whose configuration is read with `json.loads`: ```python def load_config(text: str) -> Any: return json.loads(text) def retention_days(text: str) -> int: config = load_config(text) return config["retention"]["days"] ``` `config` is `Any`. `config["retention"]` is `Any`. `config["retention"]["days"]` is `Any`, and `Any` is assignable to `int`, so the declared `-> int` is accepted with no evidence whatsoever. If the config actually carries the string `"30"`, every check between the file and the arithmetic passed, and the failure surfaces much later as a `TypeError` deep in the report code — or worse, as a silently wrong number. ## Where the `Any` comes from It is worth being able to list the sources, because "we do not write `Any`" is not the same as "we do not have `Any`": - **Deserialization.** `json.loads` and `pickle.loads` are annotated to return `Any`, because they genuinely cannot know. - **Unannotated code.** A function with no annotations has implicitly `Any` parameters and an `Any` return, and by default many checkers do not even look inside it. - **`dict[str, Any]` and `**kwargs: Any`.** The most common containers in application code; every value read out is unchecked. - **Dependencies without type information.** An untyped import makes every symbol from it `Any`. - **Dynamic access.** `getattr` with a non-literal attribute name yields `Any`. ## Why it goes unnoticed for months A type error is loud: the run fails and someone fixes it. `Any` produces the opposite signal — silence. Annotation *coverage* can look excellent while checking *effectiveness* has collapsed, because every signature in the chain is annotated; it is the values flowing through them that are unchecked. On a team of eleven engineers this compounds: one person adds an untyped helper module, three shared utilities start returning `Any` through it, and everybody downstream inherits unchecked values without a single review comment being warranted, because nothing in any individual diff looks wrong. The consequence pattern that hurts most is the one that only fires on an error path. A rollback handle pulled out of an `Any`-typed config or plugin registry type-checks against any call you write, including a misspelled method name and a `None` that arrived because a key was absent. The happy path never touches it. Then a partial failure happens mid-run, the rollback path executes for the first time in production, and the generator leaves half the report tables written and half rolled back — a defect that a checked type would have caught the day the code was written. ## Containing it The strategy is the same one used for untrusted input generally: **parse, do not sprinkle checks**. Convert the unknown into a known shape once, at the boundary, and make everything below the boundary work with the known shape. 1. **Validate at the edge and return a declared type.** Read the raw value, check it with `isinstance` or a validation library, and return a `TypedDict`, a dataclass or a domain object. The `Any` then dies inside one small function whose job is exactly that. 2. **Never let `Any` be a public return type.** A boundary function that hands `Any` outward exports the problem to every caller. If the shape truly is unknown, return `object` and force callers to narrow. 3. **Prefer `object` for genuine any-value parameters.** `object` accepts everything but keeps checking on, so a mistake becomes a visible error rather than silence. 4. **Make it a CI setting, not a habit.** Checkers offer strict modes that report returning an `Any` from a function declared to return something concrete, and that flag implicitly-`Any` parameters and untyped function bodies. Turning those on is the only reliable way to stop the spread across a team; individual discipline does not scale to eleven people. 5. **Introduce it as a ratchet.** On an existing codebase, turn strictness on for new and boundary modules first, and let the untouched interior stay as it is rather than blocking the change on a whole-repo fix. The underlying rule to state in an interview: `Any` is a *local* admission of ignorance, and it should be confined to the smallest possible region. The moment it is allowed to be a return type or a shared dictionary value, it stops being an admission and becomes an undocumented removal of checking from everything downstream.
- Annotation coverage in this report generator is near 100%. Why is that not evidence that the code is checked?Coverage counts signatures, not the values flowing through them. Every function can be fully annotated while the values are `Any` end to end, because the parameters were declared `Any`, or the arguments came from an `Any` expression, or a boundary function declared a concrete return type it never actually proves. The useful measure is how much of the code the checker *rejects* when strict settings are turned on, not how many colons are present.
- Why would you return `object` rather than `Any` from a function whose result really is unknown?Because `object` keeps checking switched on. Callers cannot subscript it, call it or do arithmetic on it without narrowing first, so the place where the unknown value is misused becomes a reported error rather than silence. `Any` would let every one of those uses through and would hand `Any` on to the next expression, exporting the loss of checking to everyone downstream.
- How would you introduce strict settings on an existing codebase without stalling the team?As a ratchet rather than a big-bang fix: enable the strict flags for new modules and for the boundary modules that produce the `Any` in the first place, leave the untouched interior at its current level, and require that any module a change touches does not regress. That converts an unbounded remediation project into per-change work, and it prioritises exactly the edges where the unchecked values enter.
One unlabelled ingredient at the start of a production line: every dish downstream inherits the mystery, and no inspector ever flags it, because nothing was ever claimed about it in the first place.
saying these in an interview costs you the question
- Thinks Any only affects the line it is written on
- Believes an annotated signature means the values are checked
- Adds Any downstream to make the checker stop complaining
- Cannot name json.loads or untyped imports as Any sources
- Treats annotation coverage as a checking guarantee
- Expects the interpreter to catch the mismatch at runtime