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=Truemakes assignment to a field raise,kw_only=True(Python 3.10 and later) makes all fields keyword-only, andslots=True(Python 3.10 and later) generates__slots__. Fields are attributes, so renames and "find usages" work. The docs are clear that nothing in@dataclasschecks 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 describingdictobjects whose keys arestrwith 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 withisinstance. Keys are required unless the class setstotal=Falseor marks themNotRequired;RequiredandNotRequiredoverridetotalper key. A TypedDict is generally not assignable todict[...]orMapping, with the exception ofMapping[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. Thetyping.NamedTuplepage 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.
# 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// 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.
# 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}"// 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
@dataclassdoes none. - **
TypedDictfits JSON-shaped data.** It stays a plain dict at runtime, sojson.dumpsworks and no conversion is needed. The price is that it can't carry methods, and it can't be checked withisinstance. - Open maps are legitimate. A lookup from SKU to price is not a record. Type it as
dict[str, int]orRecord<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 = Nonein a dataclass andNotRequiredin 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
## 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.asdictand**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 aLiteralkey type or explicit attributes. - **
TypedDictanddictmismatch.** A function typeddict[str, object]won't accept a TypedDict, per the specification. TakeMapping[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 usefield(default_factory=list). Checked with Python 3.14.7: the decorator raisesValueError: mutable default <class 'list'> for field items is not allowed: use default_factorywhile building the class, and thedefault_factoryform 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 todictandMapping): 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?
- 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