For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Types and contracts

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# 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
typescript// 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# 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}"
typescript// 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

ToolSettingWhat it catchesChecked
pyrightreportTypedDictNotRequiredAccess (error in basic, standard and strict)reading a not-required TypedDict key without checking it first2026-10-30
pyrightreportUnknownMemberType, reportUnknownVariableType (error in strict)values read from untyped dicts that end up with unknown types2026-10-30
mypy--disallow-any-generics (in --strict)bare dict, which has unknown key and value types2026-10-30
mypy--disallow-any-explicit (not in --strict)dict[str, Any]2026-10-30
ruffANN401 any-typeAny as an argument or return annotation2026-10-30
tsconfignoUncheckedIndexedAccess, noPropertyAccessFromIndexSignature (neither is in strict)unchecked reads and dot access through index signatures2026-10-30
typescript-eslintno-explicit-any (in recommended)Record<string, any> and { [k: string]: any }2026-10-30
typescript-eslintno-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 variable2026-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)
  • AR-11 Why does my agent break calls that go through **kwargs?
  • AR-13 Why is Any a blind spot for coding agents?
  • AR-15 How do I make duck typing visible to type checkers?
  • AR-17 Which type checker settings make code easier for agents?

Last verified

For agentsthis page as Markdown/agent-readable/ar-16.mdllms.txt