Interfaces (Protocols)
An interface is a structural contract: any type that provides the listed members with compatible signatures satisfies the interface, without an explicit implements clause. Interfaces emit as typing.Protocol subclasses, so mypy / pyright / IDEs see them the same way.
Declaration
interface Drawable: def draw(self) -> None def width(self) -> float def height(self) -> floatThe bodies are signatures, not implementations. (... is implicit; you can write it explicitly if you prefer.)
A class with matching members satisfies the interface — no opt-in:
interface Drawable: def draw(self) -> None def width(self) -> float def height(self) -> float
class Button: label: str
impl Button: def draw(self) -> None: print(f"[ {self.label} ]") def width(self) -> float: return float(len(self.label) + 4) def height(self) -> float: return 1.0
def render(d: Drawable) -> None: d.draw()
render(Button(label="click me")) # ✅ Button satisfies Drawablefrom typing import Protocolimport dataclasses
class Drawable(Protocol): def draw(self) -> None: ...
def width(self) -> float: ...
def height(self) -> float: ...
@dataclasses.dataclass(slots=True)class Button: label: str
def draw(self) -> None: print(f"[ {self.label} ]")
def width(self) -> float: return float(len(self.label) + 4)
def height(self) -> float: return 1.0
def render(d: Drawable) -> None: d.draw()
render(Button(label="click me"))Lowering
interface Drawable: def draw(self) -> None def width(self) -> floatfrom typing import Protocol
class Drawable(Protocol): def draw(self) -> None: ... def width(self) -> float: ...Protocol is part of the typing stdlib (PEP 544). Every Python tool already understands it.
Conformance checking
The checker verifies, at the call site (or assignment), that the candidate type provides every required member with compatible signatures. Recursion in signatures is handled via memoised “assumed subtype” sets.
A member is:
- A method (
def name(self, ...) -> R). - A field (
name: T).
Method signatures must match parameter and return types, and parameter names must match too, since a caller may pass them by keyword. The implementation must accept every call the interface allows: it may add parameters only if they have defaults, and a parameter the interface makes optional must be optional in the implementation as well.
An interface field is writable through the interface, so the implementation must provide a writable field of the same type. A frozen class, a getter-only @property, or a field with a narrower element type (list[int] for an interface’s list[object]) does not conform: a write through the interface would fail, or store a value the implementation’s type does not allow.
isinstance against an interface is rejected
def render_if_able(x: object) -> None: if isinstance(x, Drawable): # ❌ tyc::interface_isinstance x.draw()error[tyc::interface_isinstance]: runtime `isinstance` against an interface is unsafe by default; use static narrowing or opt into `@runtime_checkable` explicitlyWhy? PEP 544’s @runtime_checkable validates attribute presence at runtime — it does not check signatures or types. isinstance(x, Drawable) would return True for a class with a draw field (even a string!) and no width / height methods. The runtime check is much weaker than the static check Typhon already does.
If you genuinely need a runtime predicate, write one:
def is_drawable(x: object) -> bool: return ( callable(getattr(x, "draw", None)) and callable(getattr(x, "width", None)) and callable(getattr(x, "height", None)) )Or refactor to a sealed union, where exhaustiveness gives you stronger guarantees.
When to reach for an interface
- Multiple unrelated types share behaviour. A sealed union doesn’t fit (the variants aren’t fixed); inheritance doesn’t fit (the types are unrelated).
- The set of conforming types is open. Third-party callers can write their own implementations.
For closed sets, prefer a sealed union — exhaustiveness is stronger than structural conformance.
Generic interfaces
interface Container[T]: def add(self, item: T) -> None def items(self) -> list[T]Same PEP 695 syntax as generic classes. Conformance is checked with T bound:
class IntBag: bag: list[int]
impl IntBag: def add(self, item: int) -> None: self.bag.append(item) def items(self) -> list[int]: return list(self.bag)
def consume[T](c: Container[T]) -> int: return len(c.items())
consume(IntBag(bag=[])) # ✅ T = intBounded type parameters
A bound says “any T that satisfies this interface” — see Generics.
interface Ordered: def __lt__(self, other: Self) -> bool
def min_of[T: Ordered](xs: list[T]) -> T?: ...Common mistakes
Mixing fields and methods
interface HasName: def name(self) -> str # method
class Box: name: str # field
show(Box(name="b")) # ❌ Box.name is a field; HasName expects a methodEither change the interface to a field (name: str) or the class to a method (via impl).
Trying to isinstance an interface
See above — use static narrowing, or write an explicit predicate, or refactor.
Forgetting self on interface methods
interface Drawable: def draw() -> None # ❌ missing selfInterfaces follow the same rule as impl blocks: methods take an explicit self.
Where next
interface— the reference page.- Sealed Unions — closed sums; the alternative to open structural typing.
- Generics — type parameters and bounds.