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

How do I make duck typing visible to type checkers?

Rule AR-15

Use Protocol/interfaces for duck-typed parameters.

A function that takes "anything with a send method" works with any object that has one, and that is its strength. It also means the parameter has no type, so a tool can't say which classes notifier.send(...) reaches. Write the shape the function needs as a Protocol in Python or an interface in TypeScript. Callers stay as free as before, and the call inside the function now points at one declared method.

Why it happens

Python. Duck typing is how Python already works at runtime. The trouble is only on the static side. A parameter with no annotation is Any (AR-13), and the typing docs describe the alternative directly: the typing docs say a class can be "implicitly considered a subtype" of another by static type checkers, and call this "structural subtyping (or static duck-typing)". The specification defines a protocol by listing typing.Protocol among a class's bases, and says a class is assignable to a protocol if it defines the required members with compatible types, even without inheriting from it. So the implementing classes don't change. Only the receiver gains a type.

Members can be methods, properties or annotated attributes. Attributes are declared with annotations in the class body, are readable and writable by default, and a read-only one is declared with an abstract property. Attributes assigned only through self inside a method body are not allowed as protocol members.

Two limits to know. Protocols are checked statically by default: "isinstance() and issubclass() fail for protocol types" unless the protocol is marked @runtime_checkable, and the docs say a runtime-checkable protocol "check[s] only the presence of given attributes, ignoring their type signatures". Do not use it as validation. The specification also notes that explicit subclassing of a protocol is optional, with one practical benefit, inheriting default method implementations.

TypeScript. The type system is structural already. The handbook's example: interface Pet { name: string } accepts a Dog class "because of structural typing", where "in nominally-typed languages like C# or Java, the equivalent code would be an error". So the interface form costs less than the Python one. The failure case is a parameter left untyped (implicit any under a loose config) or typed any, which turns the structural check off.

Bad and good

A notification module sends alerts through whatever channel it is given.

python# bad: alerts.py def raise_alert(notifier, message, *, urgent=False): notifier.send(message, urgent=urgent) notifier.record(message) class EmailNotifier: def send(self, message, urgent=False): ... def record(self, message): ... class PagerNotifier: def send(self, message, *, urgent): ...
typescript// bad: alerts.ts export function raiseAlert(notifier: any, message: string, urgent = false): void { notifier.send(message, urgent); notifier.record(message); } export class EmailNotifier { send(message: string, urgent = false): void {} record(message: string): void {} } export class PagerNotifier { send(message: string, opts: { urgent: boolean }): void {} }

PagerNotifier has no record, so raise_alert(PagerNotifier(), ...) sends the alert and then fails on the second line. An agent changing EmailNotifier.send to add a parameter can't list the callers through notifier.send, because notifier has no type. The PagerNotifier gap is in the code today, and nothing reports it.

python# good: alerts.py from typing import Protocol class Notifier(Protocol): def send(self, message: str, *, urgent: bool = False) -> None: ... def record(self, message: str) -> None: ... def raise_alert(notifier: Notifier, message: str, *, urgent: bool = False) -> None: notifier.send(message, urgent=urgent) notifier.record(message) class EmailNotifier: def send(self, message: str, *, urgent: bool = False) -> None: ... def record(self, message: str) -> None: ...
typescript// good: alerts.ts export interface Notifier { send(message: string, urgent: boolean): void; record(message: string): void; } export function raiseAlert(notifier: Notifier, message: string, urgent = false): void { notifier.send(message, urgent); notifier.record(message); } export class EmailNotifier implements Notifier { send(message: string, urgent: boolean): void {} record(message: string): void {} }

Now raise_alert(PagerNotifier(), ...) is a type error that names the missing record and the send parameter that is required on one side and optional on the other, and a change to Notifier.send is an error in every class that doesn't match it. In TypeScript, implements Notifier is optional but it moves the error to the class and gives a search term that finds every implementation. The handbook is explicit that "an implements clause is only a check", so parameter types still have to be written in the class. For Python, naming the protocol as a base class (class EmailNotifier(Notifier)) does the same job and is allowed by the specification.

Trade-offs and when duck typing without a protocol is acceptable

  • A protocol is a small contract, and you write it. For a one-method dependency used in one place, the extra class is overhead. Use it where the dependency is shared, injected or passed across modules (AR-40).
  • Structural typing doesn't record who implements it. The implementers don't mention Notifier, so "find every class that implements this" isn't a text search. Subclass the protocol explicitly, or use implements, where you want that list to exist.
  • Narrow protocols compose. Define what the function needs, not what the biggest implementer offers. A protocol with two methods is easier to satisfy and easier to fake in a test than a full class.
  • Abstract base classes (abc.ABC) are the nominal alternative: implementers inherit and register by name, and isinstance works. That suits a closed family you control. Protocols suit code you import from elsewhere or test with stand-ins.
  • Truly generic helpers (len-like functions over anything sized) already have protocols in the standard library. Use those.

Enforce it

ToolSettingWhat it catchesChecked
pyrightreportMissingParameterType (error in strict)the unannotated duck-typed parameter itself; self and cls are exempt2026-10-30
pyrightreportUnknownParameterType, reportUnknownMemberType, reportUnknownArgumentType (error in strict)calls and attribute reads on values with no inferable type2026-10-30
mypy--disallow-untyped-defs (in --strict)functions with missing parameter annotations2026-10-30
ruffANN001 missing-type-function-argument, ANN401 any-typean unannotated parameter, and a parameter written as Any2026-10-30
tsconfignoImplicitAny (on under strict)a parameter with no type and nothing to infer it from2026-10-30
typescript-eslintno-explicit-any (in recommended), no-unsafe-call, no-unsafe-member-access (in recommended-type-checked)a parameter written any, and calls and member reads through it2026-10-30

None of these rules says "use a protocol here". The lever is that once the parameter has any type at all, the checker verifies the call sites. The protocol is the way to give it one without importing the implementers.

Rule block for AGENTS.md / CLAUDE.md

markdown## Duck-typed parameters - A parameter used through its methods or attributes gets a `Protocol` (Python) or `interface` (TypeScript) that lists exactly the members the function uses. - Put the protocol next to the function that needs it, or in a `protocols`/`types` module the consumers import. - Don't leave such a parameter unannotated or typed `Any`/`any`. - When you change a protocol member, update every class the type checker reports, and grep for `Protocol`/`implements` references to find explicit implementers. - Don't use `@runtime_checkable` as validation. It checks that attributes exist, not their types.

Edge cases

  • Protocol with attributes, not only methods. Annotated class-body attributes are members, and they are writable by default. A frozen dataclass, which only reads the field, doesn't satisfy a writable attribute. Declare read-only members as properties. Checked with pyright 1.1.414: passing a frozen dataclass to a parameter typed with such a protocol gave one reportArgumentType error, "name" is not read-only in protocol.
  • **@runtime_checkable in Python 3.15 and later.** The typing docs say calling isinstance() on a protocol that inherits from a runtime-checkable one, without being decorated itself, is deprecated and will raise TypeError in Python 3.20.
  • Protocol methods with default bodies are inherited only by explicit subclasses.
  • TypeScript optional members. The handbook says optional and required parameters are interchangeable when comparing functions, so a protocol method with a required parameter accepts an implementation where it is optional.

What LLMGRAPH shows

A call on an unannotated parameter prints maybe_more ... (open: type unknown). With a Protocol annotation the call prints certain against the protocol's method.

References

  • Python typing docs, Protocol and runtime_checkable: https://docs.python.org/3/library/typing.html#typing.Protocol
  • Typing specification, protocols (members, explicit subclassing, runtime checking): https://typing.python.org/en/latest/spec/protocol.html
  • TypeScript handbook, type compatibility (structural typing): https://www.typescriptlang.org/docs/handbook/type-compatibility.html
  • TypeScript handbook, classes (implements): https://www.typescriptlang.org/docs/handbook/2/classes.html
  • TypeScript handbook, everyday types (interfaces): https://www.typescriptlang.org/docs/handbook/2/everyday-types.html
  • 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 rules: https://docs.astral.sh/ruff/rules/any-type/
  • TSConfig reference: https://www.typescriptlang.org/tsconfig/
  • typescript-eslint: https://typescript-eslint.io/rules/no-explicit-any/ · https://typescript-eslint.io/rules/no-unsafe-call/ · https://typescript-eslint.io/rules/no-unsafe-member-access/

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-13 Why is Any a blind spot for coding agents?
  • AR-14 How should I type callbacks so agents can follow them?
  • AR-16 Why do dict-shaped objects make agents guess?
  • AR-09 How should plugin registries be written for coding agents?
  • AR-40 How do DI containers hide calls from coding agents?

Last verified

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