Agent Rules / Types and contracts
Why is Any a blind spot for coding agents?
Rule AR-13
Annotate public function parameters and returns. Use Any/any only at a documented boundary.
A value typed Any (Python) or any (TypeScript) switches the checker off for everything done with it. A method call on such a value has no declaration to point at, so no tool can say which class's method runs, and an agent that renames or re-signs that method gets no error from the places that call it. Give public functions real parameter and return types, and keep Any at the one spot where untyped data enters, with a comment saying so.
Why it happens
Python. The typing specification defines Any as "an unknown static type" and says that, because of this, "the static type checker can't check type correctness of operations on expressions typed as Any." It is consistent with every type in both directions: "Any is consistent with every type, and every type is consistent with Any." The typing docs add that "all functions without a return type or parameter types will implicitly default to using Any". So Any arrives in three ways, not one:
- Written:
def f(x: Any). - Implied: a parameter with no annotation. Nothing in the file says it is missing a type.
- Inherited: a value that comes from an untyped library, or from a function that returns
Any, carriesAnyinto every variable it is assigned to.
The third way is the quiet one. mypy's docs say that when a package ships neither stubs nor a py.typed marker, "mypy will assume the type of that module is Any, the dynamic type", and that errors on those values can go unnoticed. The typing specification says package authors "MUST add a marker file named py.typed" for their typing to be used, and defines stub packages (foopkg-stubs) for packages that don't ship types. Pyright has its own word for the inferred case: when a symbol's type cannot be inferred, it "sets its type to Unknown, which is a special form of Any", and that lets it warn about undeclared types. Pyright also infers an unannotated parameter from its default value, so def f(retries=3) is int, while a default of None gives Unknown | None.
**Any versus object.** The typing docs separate the two: "Use object to indicate that a value could be any type in a typesafe manner. Use Any to indicate that a value is dynamically typed." Every type is a subtype of object, but object is not a subtype of every type, so the checker still rejects x.send() on an object. A parameter typed object that is then narrowed with isinstance is a valid boundary. A parameter typed Any is not narrowed by anything.
TypeScript. The handbook describes any as the type to use "whenever you don't want a particular value to cause typechecking errors". With it you can "access any properties of it (which will in turn be of type any), call it like a function, assign it to (or from) a value of any type". The any spreads: a property read from an any value is any too. When you leave a type off and TypeScript can't infer one, "the compiler will typically default to any", and noImplicitAny turns that into an error.
TypeScript 3.0 added unknown as the safe counterpart. "Anything is assignable to unknown, but unknown isn't assignable to anything but itself and any without a type assertion or a control flow based narrowing", and "no operations are permitted on an unknown without first asserting or narrowing". unknown is the TypeScript equivalent of Python's object here.
Gradual typing is why this is allowed. Both languages let you add types a function at a time. The specification's gradual guarantee says removing annotations "should not result in additional static type errors", which is why an untyped corner never fails the build. It is also why the checker reports nothing about the calls made from it.
Bad and good
An invoice service prices lines. The Line model is typed in one module and consumed through Any in another.
# bad: pricing.py
from typing import Any
def price_line(line: Any, discounts: Any) -> Any:
base = line.unit_price * line.qty
return base - discounts.get(line.sku, 0)
def invoice_total(lines, discounts): # no annotations: implicit Any
return sum(price_line(l, discounts) for l in lines)// bad: pricing.ts
export function priceLine(line: any, discounts: any): any {
const base = line.unitPrice * line.qty;
return base - (discounts[line.sku] ?? 0);
}
export function invoiceTotal(lines, discounts) { // implicit any under a loose config
return lines.reduce((sum, l) => sum + priceLine(l, discounts), 0);
}Now an agent changes Line.unit_price to Line.unit_cents (an integer) and updates the Line class and its tests. The checker has nothing to say about line.unit_price in price_line, because line is Any. The old name stays, fails at runtime on the first priced line, and the arithmetic downstream is typed Any as well.
# good: pricing.py
from dataclasses import dataclass
from typing import Mapping
@dataclass(frozen=True)
class Line:
sku: str
unit_cents: int
qty: int
def price_line(line: Line, discounts: Mapping[str, int]) -> int:
return line.unit_cents * line.qty - discounts.get(line.sku, 0)
def invoice_total(lines: list[Line], discounts: Mapping[str, int]) -> int:
return sum(price_line(l, discounts) for l in lines)
# boundary: the only place untyped data enters. Everything past this line is typed.
def load_line(raw: object) -> Line:
if not isinstance(raw, dict):
raise ValueError("line must be an object")
return Line(sku=str(raw["sku"]), unit_cents=int(raw["unit_cents"]), qty=int(raw["qty"]))// good: pricing.ts
export interface Line {
sku: string;
unitCents: number;
qty: number;
}
export function priceLine(line: Line, discounts: Record<string, number>): number {
return line.unitCents * line.qty - (discounts[line.sku] ?? 0);
}
export function invoiceTotal(lines: Line[], discounts: Record<string, number>): number {
return lines.reduce((sum, l) => sum + priceLine(l, discounts), 0);
}
// boundary: the only place untyped data enters. Everything past this line is typed.
export function loadLine(raw: unknown): Line {
if (typeof raw !== "object" || raw === null) throw new Error("line must be an object");
const r = raw as Record<string, unknown>;
return { sku: String(r.sku), unitCents: Number(r.unitCents), qty: Number(r.qty) };
}Now the same rename shows up as an error on every use of the old name, and a search for unit_cents or unitCents finds the real readers. The boundary function does the one job Any was papering over, which is deciding what the incoming data is. See AR-16 for what to put in the type when the data is dict-shaped.
Trade-offs and when Any is acceptable
- **
Anycosts nothing to write.** On a prototype, a script or a spike, it is the right call, and gradual typing exists so you can make it. The rule is about code that other code depends on. - Real boundaries exist. Data from JSON, a database driver, an untyped library or a plugin can't be known statically. Take it as
objectorunknown, narrow it once, and return a typed value. That is more lines thanAny, and each of them is a check the runtime was going to need. - Wide types are honest.
Mapping[str, object]orRecord<string, unknown>for a bag of data says what is true.dict[str, Any]says nothing, and every value read from it isAny(AR-16). - A typed signature is a promise the body has to keep. Annotating a public function with a precise type can surface errors in its callers on the first run. Roll it out by package (AR-17).
- Tests and generated glue can use
Anywhere the shapes are throwaway. Keep them off the paths agents change.
Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| ruff | ANN001 missing-type-function-argument, ANN201 missing-return-type-undocumented-public-function | missing parameter and public return annotations | 2026-10-30 |
| ruff | ANN401 any-type | Any written as an argument or return annotation. Type aliases of Any are unsupported and can cause false positives | 2026-10-30 |
| pyright | reportMissingParameterType (none in basic and standard, error in strict) | parameters without annotation; self and cls are exempt | 2026-10-30 |
| pyright | reportUnknownParameterType, reportUnknownArgumentType, reportUnknownVariableType, reportUnknownMemberType (same defaults) | values pyright could not infer, which is how inherited Any shows up | 2026-10-30 |
| pyright | reportMissingTypeStubs (error in strict) | imports with no stub file | 2026-10-30 |
| mypy | --disallow-untyped-defs, --disallow-incomplete-defs (in --strict) | functions with no or partial annotations | 2026-10-30 |
| mypy | --disallow-any-explicit (not in --strict) | Any written in annotations | 2026-10-30 |
| mypy | --disallow-any-unimported (not in --strict) | types that become Any because their import was not followed | 2026-10-30 |
| mypy | --disallow-any-generics, --warn-return-any (in --strict) | bare list, and returning an Any value from a function with a real return type | 2026-10-30 |
| tsconfig | noImplicitAny (on under strict) | any type TypeScript would have inferred as any | 2026-10-30 |
| tsconfig | useUnknownInCatchVariables (on under strict) | catch (e) being typed unknown instead of any | 2026-10-30 |
| typescript-eslint | no-explicit-any (in recommended), option fixToUnknown | any written in a type position; the fix can rewrite it to unknown | 2026-10-30 |
| typescript-eslint | no-unsafe-argument, no-unsafe-assignment, no-unsafe-return, no-unsafe-call, no-unsafe-member-access (in recommended-type-checked, need type information) | an any value passed, assigned, returned, called or read from | 2026-10-30 |
| typescript-eslint | explicit-module-boundary-types (not in a preset per its docs page), option allowArgumentsExplicitlyTypedAsAny | exported functions and public methods missing parameter or return types | 2026-10-30 |
Pyright's reportUnknown* family catches the inherited kind that ANN401 and --disallow-any-explicit can't see, because no Any is written anywhere. Turn those on for the paths agents edit (AR-17).
Rule block for AGENTS.md / CLAUDE.md
## Types at boundaries
- Annotate every parameter and return type on public functions. No implicit Any.
- Don't write `Any` or `any` to make an error go away. Use a precise type, `object`/`unknown`, or a Protocol/interface.
- Untyped data (JSON, DB rows, untyped libraries) enters through one function that takes `object`/`unknown`, narrows it, and returns a typed value. Mark that function with a comment starting "boundary:".
- If you must write Any, add a comment on the same line saying why and what the real type is.
- After changing a function with Any in its signature, search for its callers by hand: the type checker can't list them.Edge cases
- **
cast(Foo, x)andx as Foo** assert a type without checking it. They areAnywith extra steps, so treat them as boundaries and comment them. - **
# type: ignoreand@ts-ignore** suppress the error and leave the value's type as it was. A suppressed line is a blind spot the checker has agreed not to mention. - **
Callable[..., Any]and(...args: any[]) => any** areAnyfor call signatures (AR-14). The same goes for*args: Any, **kwargs: Any(AR-11). - **
dict[str, Any]andRecord<string, any>** make every value anAny. See AR-16. - **An explicit
Anyis invisible to the pyrightreportUnknown*rules.** Pyright's docs describe Unknown as the inferred form of Any. Checked with pyright 1.1.414 in strict mode: an explicitAnyon a parameter, a return type and a variable produced no diagnostic, while an unannotated function in the same file was flagged (reportUnknownParameterType,reportUnknownVariableType,reportMissingParameterType). - Untyped dependencies. Prefer a stub package or the library's own types. Install the stubs mypy suggests (the
types-*packages), write a small stub for what you use, or wrap the library in one typed module and import it only there. - **
Parameters<any>** isunknown[]andReturnType<any>isany, per the utility-types page, so a utility type over ananyfunction gives you ananyback. - **Classes that
implementsan interface** don't pick up the interface's parameter types. The TypeScript handbook saysimplements"doesn't change the type of the class or its methods at all", so a method parameter left untyped is still an implicitany. - Data classes that wrap untyped fields push the problem inward. Type the fields.
What LLMGRAPH shows
A method called on an Any, unannotated, **kwargs or untyped-import value prints maybe_more ... (open: type unknown); with real types it prints certain.
References
- Python typing docs, the
Anytype andAnyversusobject: https://docs.python.org/3/library/typing.html#the-any-type - Typing specification, concepts (gradual types, consistency): https://typing.python.org/en/latest/spec/concepts.html
- Typing specification, distributing type information (stub files,
py.typed, stub-only packages): https://typing.python.org/en/latest/spec/distributing.html - Pyright, type inference (Unknown, unannotated parameters): https://raw.githubusercontent.com/microsoft/pyright/main/docs/type-inference.md
- Pyright configuration: https://raw.githubusercontent.com/microsoft/pyright/main/docs/configuration.md
- mypy command line (
--strict,--disallow-any-*): https://mypy.readthedocs.io/en/stable/command_line.html - mypy, running mypy and missing stubs: https://mypy.readthedocs.io/en/stable/running_mypy.html
- Ruff ANN401: https://docs.astral.sh/ruff/rules/any-type/ · rule list: https://docs.astral.sh/ruff/rules/
- TypeScript handbook, everyday types (
any,noImplicitAny): https://www.typescriptlang.org/docs/handbook/2/everyday-types.html - TypeScript 3.0 release notes (
unknown): https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-0.html - TypeScript handbook, classes (
implements): https://www.typescriptlang.org/docs/handbook/2/classes.html - TypeScript utility types: https://www.typescriptlang.org/docs/handbook/utility-types.html
- TSConfig reference: https://www.typescriptlang.org/tsconfig/
- typescript-eslint: https://typescript-eslint.io/rules/no-explicit-any/ · https://typescript-eslint.io/rules/no-unsafe-argument/ · https://typescript-eslint.io/rules/no-unsafe-assignment/ · https://typescript-eslint.io/rules/no-unsafe-return/ · https://typescript-eslint.io/rules/no-unsafe-call/ · https://typescript-eslint.io/rules/no-unsafe-member-access/ · https://typescript-eslint.io/rules/explicit-module-boundary-types/
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-14 How should I type callbacks so agents can follow them?
- AR-15 How do I make duck typing visible to type checkers?
- AR-16 Why do dict-shaped objects make agents guess?
- AR-17 Which type checker settings make code easier for agents?
Last verified