# Why do dict-shaped objects make agents guess?

> Rule AR-16: Prefer dataclasses/TypedDict/interfaces over free-form dicts for structured data passed between modules.

A plain dict carries its field names as strings, so the only record of what keys exist is every place that happens to read or write one. An agent that renames `customer_id` has to find each `order["customer_id"]` by text search, and a miss doesn't fail until that line runs. A dataclass, `TypedDict` or `interface` puts the field list in one place and turns each read into a checked attribute or key.

## Why it happens
**Python.** `dict[str, Any]` (or an unparameterised `dict`) says that keys are strings and values are anything. Reading `order["total"]` gives `Any` (AR-13), and so does everything computed from it. The key itself is a string literal, so a language server rename of the field can't reach it, and the same name spelled slightly differently two modules away is a different key with no warning.

Python has three typed shapes, with different jobs:
- **`@dataclass`.** The docs say it "add[s] generated special methods to classes", producing `__init__`, `__repr__` and `__eq__` from the annotated fields. `frozen=True` makes assignment to a field raise, `kw_only=True` (Python 3.10 and later) makes all fields keyword-only, and `slots=True` (Python 3.10 and later) generates `__slots__`. Fields are attributes, so renames and "find usages" work. The docs are clear that nothing in `@dataclass` checks annotations at runtime: `Item(quantity="not a number")` is accepted. Type checkers catch that at the call site, and validation at the boundary is yours to write (AR-13).
- **`TypedDict`.** The typing specification defines it as describing `dict` objects whose keys are `str` with a specified value type per key. It is for data that must stay a dict, such as JSON you pass along. "When called, the TypedDict type object returns an ordinary dictionary object at runtime", so it adds no runtime check, and it can't be used with `isinstance`. Keys are required unless the class sets `total=False` or marks them `NotRequired`; `Required` and `NotRequired` override `total` per key. A TypedDict is generally not assignable to `dict[...]` or `Mapping`, with the exception of `Mapping[str, object]`.
- **`NamedTuple`.** Positional and read-only by field. The Python docs describe a named tuple as a tuple subclass whose fields are reachable by attribute name and by index, and the typing specification says type checkers should enforce that named tuple fields cannot be overwritten or deleted. The `typing.NamedTuple` page itself doesn't use the word "immutable".

**TypeScript.** An object type lists properties and their types, and the handbook notes that "if you don't specify a type, it will be assumed to be `any`". `interface Point { x: number; y: number }` names the shape once. Writing `Record<string, any>` or `{ [key: string]: any }` throws that away: every property read is `any`. Two compiler flags change how index signatures behave. `noUncheckedIndexedAccess` "will add `undefined` to any un-declared field in the type", so a read through an index signature must be checked before use. `noPropertyAccessFromIndexSignature` forces bracket syntax for index-signature properties; its stated goal is "to signal intent in your calling syntax about how certain you are this property exists". Neither is part of `strict`.

## Bad and good
An order record passes from a checkout module to fulfilment and to a receipt printer.

```python tab
# bad: orders.py
def build_order(cart, customer):
    return {
        "id": new_id(),
        "customer_id": customer["id"],
        "lines": [{"sku": l["sku"], "qty": l["qty"]} for l in cart],
        "ship_to": customer.get("address"),
    }

# fulfilment.py
def pick_list(order):
    return [(l["sku"], l["qty"]) for l in order["lines"]]

# receipts.py
def header(order):
    return f"Order {order['id']} for {order['customerId']}"      # key spelled differently
```

```ts tab
// bad: orders.ts
export function buildOrder(cart: any[], customer: any): Record<string, any> {
  return {
    id: newId(),
    customerId: customer.id,
    lines: cart.map(l => ({ sku: l.sku, qty: l.qty })),
    shipTo: customer.address,
  };
}

// receipts.ts
export function header(order: Record<string, any>): string {
  return `Order ${order.id} for ${order.customer_id}`;      // key spelled differently
}
```

The receipt module reads a key nobody writes. The Python version raises `KeyError` on the first receipt, and the TypeScript version prints "for undefined". An agent asked to add `ship_to` handling or rename `lines` to `items` has no list of readers. Text search finds `"lines"` and `.lines` in unrelated places, and a miss is a runtime failure.

```python tab
# good: orders.py
from dataclasses import dataclass

@dataclass(frozen=True, kw_only=True)
class OrderLine:
    sku: str
    qty: int

@dataclass(frozen=True, kw_only=True)
class Order:
    id: str
    customer_id: str
    lines: list[OrderLine]
    ship_to: str | None = None

def build_order(cart: list[CartItem], customer: Customer) -> Order:
    return Order(
        id=new_id(),
        customer_id=customer.id,
        lines=[OrderLine(sku=l.sku, qty=l.qty) for l in cart],
        ship_to=customer.address,
    )

# receipts.py
def header(order: Order) -> str:
    return f"Order {order.id} for {order.customer_id}"
```

```ts tab
// good: orders.ts
export interface OrderLine {
  sku: string;
  qty: number;
}

export interface Order {
  id: string;
  customerId: string;
  lines: OrderLine[];
  shipTo?: string;
}

export function buildOrder(cart: CartItem[], customer: Customer): Order {
  return {
    id: newId(),
    customerId: customer.id,
    lines: cart.map(l => ({ sku: l.sku, qty: l.qty })),
    shipTo: customer.address,
  };
}

// receipts.ts
export function header(order: Order): string {
  return `Order ${order.id} for ${order.customerId}`;
}
```

A misspelled field is an error in the editor and in the type checker. Renaming `lines` renames every reader. A search for `OrderLine` or `Order` lists every module that handles the shape. Where the data has to remain a dict (it is going straight to or from JSON), use a TypedDict with the same fields and parse into the dataclass at the edge.

## Trade-offs and when a dict is acceptable
- **Dicts are flexible and cheap.** Merging, filtering, sorting and serialising a dict take no ceremony, and a spike or a script doesn't need a class. The cost arrives when a second module reads the keys.
- **Data classes add construction.** You write the class, and the parse step that turns incoming JSON into it. That step is where validation belongs, since `@dataclass` does none.
- **`TypedDict` fits JSON-shaped data.** It stays a plain dict at runtime, so `json.dumps` works and no conversion is needed. The price is that it can't carry methods, and it can't be checked with `isinstance`.
- **Open maps are legitimate.** A lookup from SKU to price is not a record. Type it as `dict[str, int]` or `Record<string, number>`, with a real value type.
- **Frozen dataclasses** make changes to a shared record a visible act (build a new one) instead of a write from any module. That is the point of `frozen=True`, and it costs a copy when you change a field.
- **Optional fields need a decision.** Mark them `X | None = None` in a dataclass and `NotRequired` in a TypedDict, so the type says when a key may be absent.

## Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| pyright | `reportTypedDictNotRequiredAccess` (error in basic, standard and strict) | reading a not-required TypedDict key without checking it first | 2026-10-30 |
| pyright | `reportUnknownMemberType`, `reportUnknownVariableType` (error in strict) | values read from untyped dicts that end up with unknown types | 2026-10-30 |
| mypy | `--disallow-any-generics` (in `--strict`) | bare `dict`, which has unknown key and value types | 2026-10-30 |
| mypy | `--disallow-any-explicit` (not in `--strict`) | `dict[str, Any]` | 2026-10-30 |
| ruff | `ANN401` any-type | `Any` as an argument or return annotation | 2026-10-30 |
| tsconfig | `noUncheckedIndexedAccess`, `noPropertyAccessFromIndexSignature` (neither is in `strict`) | unchecked reads and dot access through index signatures | 2026-10-30 |
| typescript-eslint | `no-explicit-any` (in `recommended`) | `Record<string, any>` and `{ [k: string]: any }` | 2026-10-30 |
| typescript-eslint | `no-unsafe-member-access`, `no-unsafe-assignment` (in `recommended-type-checked`, need type information) | reading a property from an `any` value, and assigning an `any` into a variable | 2026-10-30 |

No tool in the pages checked flags "a dict used as a record" by name. The lever is making the dict's value type something other than `Any`, which makes every key read a checked expression.

## Rule block for AGENTS.md / CLAUDE.md
```markdown
## Structured data
- A record that crosses a module boundary is a dataclass (Python) or interface (TypeScript). Use a TypedDict only where the data has to stay a plain dict (JSON in and out).
- Don't pass `dict[str, Any]` or `Record<string, any>` between modules. Use a dict only for open maps with a real value type.
- Build the typed object from raw data in one function at the boundary; validate there, because dataclasses and TypedDicts don't validate at runtime.
- When adding or renaming a field, change the class first, then fix each place the type checker reports.
```

## Edge cases
- **`dataclasses.asdict` and `**obj.__dict__`** turn a record back into a dict. Fine at the serialisation edge; avoid in the middle of the call chain.
- **Dict keys built from variables** (`order[field_name]`) are the same pattern as AR-07. Use a `Literal` key type or explicit attributes.
- **`TypedDict` and `dict` mismatch.** A function typed `dict[str, object]` won't accept a TypedDict, per the specification. Take `Mapping[str, object]` if you must accept both.
- **Unknown extra keys.** The TypedDict constructor returns an ordinary dictionary at runtime, so a TypedDict object may carry keys the type doesn't list; do not rely on the type to strip them. The typing specification uses "openness" for whether a TypedDict allows extra keys (open, closed, or `extra_items`), and that governs static checking and the constructor, not what a dictionary stores.
- **Validation libraries** exist for the parse step. This page doesn't compare them. Whatever you use, return a typed object from it.
- **Mutable defaults.** A dataclass field default of `[]` is rejected by the dataclass decorator, so use `field(default_factory=list)`. Checked with Python 3.14.7: the decorator raises `ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory` while building the class, and the `default_factory` form runs.
- **GraphQL, ORM rows and API responses.** Treat the library's row or response object as the boundary and convert to your own type once.

## References
- Python dataclasses docs (`dataclass`, `frozen`, `slots`, `kw_only`, no runtime type check): https://docs.python.org/3/library/dataclasses.html
- Typing specification, `TypedDict` (`total`, `Required`, `NotRequired`, runtime behaviour, assignability to `dict` and `Mapping`): https://typing.python.org/en/latest/spec/typeddict.html
- Python typing docs, `Required`, `NotRequired`, `TypedDict`: https://docs.python.org/3/library/typing.html
- TypeScript handbook, everyday types (object types, interfaces): https://www.typescriptlang.org/docs/handbook/2/everyday-types.html
- TypeScript utility types (`Readonly`, `Partial`, `Pick`): https://www.typescriptlang.org/docs/handbook/utility-types.html
- TSConfig reference (`noUncheckedIndexedAccess`, `noPropertyAccessFromIndexSignature`): https://www.typescriptlang.org/tsconfig/
- Pyright configuration: https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md
- mypy command line: https://mypy.readthedocs.io/en/stable/command_line.html
- Ruff ANN401: https://docs.astral.sh/ruff/rules/any-type/
- typescript-eslint: https://typescript-eslint.io/rules/no-explicit-any/ · https://typescript-eslint.io/rules/no-unsafe-member-access/ · https://typescript-eslint.io/rules/no-unsafe-assignment/

## Changelog
- 2026-10-30: draft 1.
- 2026-10-30: docs checks resolved.
- 2026-10-30: tool checks resolved (pyright 1.1.414, tsc 7.0.2, eslint 9.39.5, ruff 0.17.3, mypy 2.4.1)

## Related

- [AR-11: Why does my agent break calls that go through **kwargs?](https://beta.llmgraph.in/agent-readable/ar-11.md)
- [AR-13: Why is Any a blind spot for coding agents?](https://beta.llmgraph.in/agent-readable/ar-13.md)
- [AR-15: How do I make duck typing visible to type checkers?](https://beta.llmgraph.in/agent-readable/ar-15.md)
- [AR-17: Which type checker settings make code easier for agents?](https://beta.llmgraph.in/agent-readable/ar-17.md)

Last verified 2026-10-30
