# 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 tab
# 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): ...
```

```ts tab
// 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 tab
# 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: ...
```

```ts tab
// 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
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| pyright | `reportMissingParameterType` (error in strict) | the unannotated duck-typed parameter itself; `self` and `cls` are exempt | 2026-10-30 |
| pyright | `reportUnknownParameterType`, `reportUnknownMemberType`, `reportUnknownArgumentType` (error in strict) | calls and attribute reads on values with no inferable type | 2026-10-30 |
| mypy | `--disallow-untyped-defs` (in `--strict`) | functions with missing parameter annotations | 2026-10-30 |
| ruff | `ANN001` missing-type-function-argument, `ANN401` any-type | an unannotated parameter, and a parameter written as `Any` | 2026-10-30 |
| tsconfig | `noImplicitAny` (on under `strict`) | a parameter with no type and nothing to infer it from | 2026-10-30 |
| typescript-eslint | `no-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 it | 2026-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)

## Related

- [AR-13: Why is Any a blind spot for coding agents?](https://beta.llmgraph.in/agent-readable/ar-13.md)
- [AR-14: How should I type callbacks so agents can follow them?](https://beta.llmgraph.in/agent-readable/ar-14.md)
- [AR-16: Why do dict-shaped objects make agents guess?](https://beta.llmgraph.in/agent-readable/ar-16.md)
- [AR-09: How should plugin registries be written for coding agents?](https://beta.llmgraph.in/agent-readable/ar-09.md)
- [AR-40: How do DI containers hide calls from coding agents?](https://beta.llmgraph.in/agent-readable/ar-40.md)

Last verified 2026-10-30
