skip to content

Writing Annotations

Where annotations go and what the everyday vocabulary means — Optional, unions, Any, object. Most typing mistakes interviewers see are a misused Optional or a stray Any that switches checking off.

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

questions

15

How do `typing.Any` and `object` differ as annotations to a type checker?

level: juniorimportance: must knowfreq 62%

answer

  1. two ends of the type lattice
  2. one accepts anything, one checks nothing
  3. top type versus escape hatch
  4. isinstance narrows an object parameter
  5. Any is compatible in both directions

basics

~20 s

Any switches checking off for a value: any attribute, call or assignment is allowed. object is the top type, so every value fits it, but you may use only what every object supports until you narrow with isinstance.

solid answer

~50 s

`object` is the top of the type lattice: every value is an `object`, so a parameter annotated `object` accepts anything, but inside the function a checker permits only the operations every object supports — `str()`, `repr()`, `hash()`, `==`, `is`, `isinstance`. To do more you narrow with an `isinstance` check, and the checker follows you into that branch. `typing.Any` is not a member of that lattice at all; it is the instruction to stop checking. Every operation on an `Any` value is accepted, and the *result* of that operation is itself `Any`, so it spreads. Assignment differs too: `Any` is compatible in both directions, while `object` only receives values — you cannot assign an `object` to a `str` variable without narrowing. The rule of thumb: write `object` when you genuinely accept any value, `Any` only when you know something the checker cannot.

code

python · 21 lines
python
from typing import Any


class Widget:
    def render(self) -> str:
        return "widget"


def draw(value: Any) -> None:
    print(value.render())      # a checker allows any attribute on Any


def describe(value: object) -> None:
    print(str(value))          # allowed: every object supports str()
    if isinstance(value, Widget):
        print(value.render())  # allowed only inside the isinstance branch


draw(Widget())
describe(Widget())
describe(42)

go deeper

for a junior

Recall the one-liner: object accepts every value but lets you do almost nothing with it until you check its type; Any accepts every value and lets you do everything. Be able to name isinstance as the way to get from object to a usable type.

for a middle

Explain the mechanics: Any is bidirectionally assignable and its operations yield Any again, while object sits at the top of the lattice and is assignable only in one direction. Show narrowing with isinstance and name what object genuinely supports.

for a senior

Demonstrate the review judgement: which parameters in a real codebase should be object, where Any is honest, and why replacing a failing object annotation with Any usually hides a missing narrow rather than fixing anything.

for a principal

Own the policy angle: what the team's default is at module boundaries, whether dict[str, Any] is allowed to cross into shared code, and how much unchecked surface the codebase can carry before annotations stop buying anything.

Python's type system is *gradual*: it has to describe fully annotated code and code that was never annotated, side by side, in the same program. `typing.Any` and the builtin `object` are the two tools it uses for that, and although both are often described as "anything goes", they sit in completely different places. `object` is the **top of the subtype lattice**. `Any` is **outside the lattice altogether**. ## `object`: the top type Every class in Python inherits from `object`, so every value is an `object`. A parameter annotated `object` therefore accepts anything you pass, and that is the honest way to spell "I take any value". The price is paid inside the function: a static checker will only let you use the operations `object` itself defines. That is a short list — `str()` and `repr()`, `hash()`, `==` and `!=`, `is`, `isinstance()`, and passing the value onward to something else that takes `object`. Everything else is an error: ```python def audit(value: object) -> None: print(str(value)) # fine print(value + 1) # checker error: object has no __add__ print(value.name) # checker error: object has no attribute 'name' ``` To do more you **narrow**. An `isinstance(value, int)` test inside an `if` tells the checker that within that branch the value is an `int`, and arithmetic becomes legal there. This is the whole point of `object`: it forces the question "what is this actually?" to be answered in code, not assumed. Assignment with `object` is one-directional. A `str` can be assigned to a variable declared `object`, because `str` is a subtype of `object`. The reverse is rejected: a value declared `object` cannot be assigned to a `str` variable, because the checker has no evidence it is one. ## `Any`: the escape hatch `Any` is not a type that sits above or below other types. It is a statement that checking is suspended here. It is **bidirectionally compatible**: every type is assignable to `Any`, and `Any` is assignable to every type. That second half is the dangerous one — it is why `Any` never produces an error at an assignment. Inside a function, a value typed `Any` accepts every operation: attribute access, calls, subscription, arithmetic, iteration. And crucially, the *result* of each of those operations is `Any` too. `Any` is contagious in a way `object` is not; it does not merely relax one check, it removes checking from every expression derived from it. Since Python 3.11, `Any` is also a class and may be used as a base: `class Fake(Any): ...` produces a class on which unknown attributes are accepted, which stub and test-double authors occasionally want. ## Where the difference bites: containers The distinction is sharpest with generics. `list[object]` is a list you may read anything out of, but it is not interchangeable with `list[str]` — generics are invariant, and a checker will reject passing a `list[str]` where a `list[object]` is required, because the callee could append an `int` to it. `list[Any]`, by contrast, is compatible with `list[str]` in both directions, because the element type is unchecked. `dict[str, Any]` is the most common shape in real code, and it means "the values are not checked", not "the values may be anything sensible". ## `None` and `NoneType` The leaf next to these two is `None`. In an annotation you write `None` literally — `def save(path: str) -> None:` — and the type it denotes is `types.NoneType`, re-added to the standard library in 3.10, whose only value is the singleton `None`. It is worth naming clearly because people mix it up with the bottom types: `-> None` means "this function *does* return, and the value is `None`". It is an ordinary type with exactly one member, not a type with no members. ## Choosing between them in review The question that settles almost every case is: *do I mean "any value may arrive here", or do I mean "stop checking"?* A logging helper, a generic cache key, an equality shim, a `__eq__` parameter — those accept any value and should say `object`. Deserialized JSON, a plugin loaded by name, a dependency without type stubs — there the checker genuinely cannot know, and `Any` is the honest marker, but it should be confined to the boundary and converted into a declared shape as early as possible. The practical failure mode is reaching for `Any` because `object` produced errors. Those errors were the checker doing its job: it was asking for the `isinstance` check or the narrower annotation that the code was missing.

  • A helper must accept literally any value a caller passes. Which of the two would you annotate its parameter with?
    `object`. It expresses "anything may arrive" while keeping checking switched on inside the body, so the compiler-visible contract stays honest and any real use of the value has to be justified by an `isinstance` narrow. `Any` would accept the same calls but would also silently permit the body to do nonsense with the value, and would leak unchecked types back out through the return.
  • Why is `list[Any]` more permissive than `list[object]` at a call site?
    Generics are invariant, so `list[str]` is not acceptable where `list[object]` is required — the callee could append a non-`str` and corrupt the caller's list. `list[Any]`, though, is compatible with `list[str]` in both directions because the element type is unchecked, so the invariance rule never gets a chance to fire. That is convenient and is exactly why `dict[str, Any]` hides so many real errors.
  • What can you do with `typing.Any` since Python 3.11 that you could not before?
    Use it as a base class. `Any` became a real class in 3.11, so `class Fake(Any): ...` is legal; a checker then accepts arbitrary attribute access on instances of `Fake`. It is a niche tool for test doubles and hand-written stubs that must respond to anything, and it should not appear in ordinary application classes.

object is a door anyone may walk through into a bare waiting room where there is almost nothing you are allowed to touch. Any is the same door with the guard sent home: you may go anywhere beyond it, and nobody is checking what you do.

saying these in an interview costs you the question

  • Says Any and object mean the same thing
  • Thinks object lets you call any method on the value
  • Uses Any to silence errors object correctly raised
  • Believes annotations are enforced by the interpreter at runtime
  • Cannot say that operations on Any produce Any again
  • Claims object is the bottom type of the lattice

context

open as a page

What does `Optional[str]` mean in a Python type annotation?

level: juniorimportance: must knowfreq 72%

basics

~20 s

Optional[str] from the typing module means the value is either a str or None - exactly the union str | None. It says nothing about omitting an argument: such a parameter is still required unless it also has a default.

open as a page

Are Python type annotations enforced at runtime, and what does the interpreter do with them?

level: juniorimportance: must knowfreq 75%

basics

~20 s

No. CPython never compares a value against its annotation. It only records annotations as metadata in the annotations dictionary on functions, classes and modules; enforcement comes from a separate static type checker or from library code that reads them.

open as a page

Why is an int accepted where a parameter is annotated float?

level: middleimportance: must knowfreq 58%

basics

~10 s

PEP 484 special-cases the numeric tower: an int is acceptable where float is annotated, and both where complex is. It is a type-checker convention, not subclassing, and nothing is converted at runtime.

open as a page

Why do type checkers reject `def load(path: str = None)` in Python?

level: middleimportance: must knowfreq 60%

basics

~20 s

Because the annotation says path is always a str while the default hands it None. Under the no-implicit-Optional rule a None default no longer widens the declared type, so you must write str | None = None yourself.

open as a page

Why does a parameter annotated int accept a bool argument like True?

level: juniorimportance: should knowfreq 42%

basics

~20 s

Because bool is a genuine subclass of int in Python: True is 1, False is 0, and isinstance(True, int) is True. Ordinary subtype rules therefore make every bool a valid int, and no type checker complains.

open as a page

What do the bottom types `typing.Never` and `typing.NoReturn` express in an annotation?

level: middleimportance: should knowfreq 34%

basics

~20 s

Never is the bottom type — no value has it. As a return annotation, Never and NoReturn (equivalent since 3.11) say the function never returns normally: it raises, exits or loops forever, unlike returning None.

open as a page

Why do str and bytes never substitute for each other in annotations?

level: middleimportance: should knowfreq 34%

basics

~20 s

There is no promotion between text and binary data: str and bytes share no subclass relation, and PEP 484's implicit widening is numeric-only. A checker therefore demands an explicit .encode() or .decode(), because no single correct encoding can be assumed.

open as a page

How does Python's `int | str` union spelling differ from `typing.Union[int, str]`?

level: middleimportance: should knowfreq 48%

basics

~20 s

They denote the same type. The operator form, added by PEP 604 in Python 3.10, needs no import, reads better nested, and works in isinstance; typing.Union is the older spelling, still common in pre-3.10 code.

open as a page

Why is a forward reference in a Python annotation written as a quoted string?

level: middleimportance: should knowfreq 55%

basics

~20 s

A quoted annotation is stored as the plain string, so the name inside it need not exist when the line runs. Through Python 3.13 annotations were evaluated at definition time, so a class defined later had to be quoted.

open as a page

Where does Python store a variable annotation written inside a function body?

level: middleimportance: should knowfreq 32%

basics

~20 s

Nowhere. An annotation on a local variable is neither evaluated nor recorded, so only a static checker ever sees it. Module-level and class-body annotations are different: those are stored in that module's or class's annotations dictionary.

open as a page

In a nightly report generator, how does one `typing.Any` from a JSON boundary spread through the code below it?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Any 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.

open as a page

Why does adding `| None` to a function's return annotation break callers when adding it to a parameter does not?

level: seniorimportance: should knowfreq 32%

basics

~20 s

Widening a parameter accepts everything callers already passed, so it stays compatible. Widening a return hands callers a value they were never told to handle: every use of the result must now rule None out before touching it.

open as a page

How do you write Python annotations that reference a class in a module you cannot import at runtime?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Guard the import with an if TYPE_CHECKING: block, which type checkers follow but the interpreter never executes, and write the annotation as a quoted string so nothing tries to resolve the missing name while the program runs.

open as a page

When would you annotate a parameter typing.SupportsIndex instead of int?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

typing.SupportsIndex describes any object with a lossless index method, which Python requires wherever a true integer is needed, such as sequence indexing or hex(). Annotate it in library code that forwards the value into such an operation rather than doing arithmetic on it.

open as a page