Any - the Dynamic Escape Hatch
A union says "one of these types." Sometimes you genuinely don't know the type until runtime - a parsed JSON leaf, a config value, a heterogeneous record off the wire. That's what Any is for. It's Dragon's interface{}: the same 16-byte tagged box a union uses, but able to hold anything. It exists so the rest of your program can stay strictly typed and fast
- you opt into dynamism in exactly the one corner that needs it, and nowhere else.
Holding anything
An Any slot can be reassigned across types:
x: Any = 42
print(x) # 42
x = "hello"
print(x) # hello
x = 3.14
print(x) # 3.14
The headline use: dict[str, Any]
The reason Any earns its place is dict[str, Any] - the typed map of heterogeneous values that every serialization story needs. The hot, monomorphic dicts (dict[str, int], dict[str, str]) stay native and free; Any is a second tier you reach for only where the data is actually mixed:
cfg: dict[str, Any] = {}
cfg["name"] = "my-app"
cfg["port"] = 8080
cfg["debug"] = true
port: Any = cfg["port"]
if isinstance(port, int) {
print(port + 1) # 8081 - narrowed to a native int
}
To do anything with an Any value you must narrow it with isinstance first - the compiler rejects using a raw Any as a concrete type, exactly as it does for a union. Reading a value by key and narrowing it is the correct, checked path.
Any vs list[Any]
A bare Any is layout-agnostic: it can hold any value - including a concrete list[str] - and every dynamic operation on it dispatches on the value's real runtime type. list[Any] is a stronger claim: it pins a concrete runtime layout (one 16-byte box per element), which is different from a concrete list's flat 8-byte slots. So a value moves between list[T] and list[Any] only when it is a fresh literal (built in the right layout from birth) or copied element-wise - never by aliasing an existing list under a new annotation. The compiler enforces this at assignments and call sites, and the runtime enforces it with a TypeError when unboxing an Any into a typed list view. Lists walks through the rule with examples.
What it costs
The cost is real but bounded. An Any slot is 16 bytes instead of 8; every read pays one extra load and a tag check; and you must narrow before use. That tag-check-then-unbox is the price of dynamism - cheap and predictable, but not free the way dict[str, int] is. Use Any where the data is genuinely heterogeneous; don't reach for it to dodge writing a real type. It exists precisely so the typed path stays fast: you never widen dict[str, int] to carry boxes just because one corner of the program needs dict[str, Any]. This is also why JSON decoding lands in Any - see Data Formats.
Any is not type introspection
Any defers a type to runtime by design, and isinstance is the bounded, checked way to recover it. That's the only runtime type question Dragon answers - there is no type(x) returning a type object, no getattr/hasattr, no attribute discovery. The static type system already knows the shape of everything that isn't Any; Any is the one place you explicitly hand a type to runtime, and isinstance is how you take it back.
At a glance
| You want to... | Write |
|---|---|
| A slot holding anything | x: Any = ... |
| A heterogeneous map | cfg: dict[str, Any] = {} |
| Read a value out | v: Any = cfg["k"] then narrow |
| Use it as a concrete type | if isinstance(v, int) { v + 1 } |
Any is the dynamic tier. The next chapter covers three more annotations you'll reach for constantly - function values, fixed-schema dicts, and the C-FFI integer: Callable, TypedDict, and intc.
