First steps
Understanding why pysmo is structured the way it is requires a brief look at how Python thinks about types. This section covers type hints, duck typing, and structural subtyping — the three ideas that together make protocol-based design possible. The concepts are not specific to pysmo, and are worth understanding in their own right. Those already comfortable with typing in Python can skip ahead to the next section.
Tip
Python's type system only pays off in full when your editor understands it too. A modern editor or IDE such as VSCode, PyCharm, or Neovim will flag type errors as you write, turning hints into immediate feedback.
Dynamic and static typing
Python is a dynamically typed language: the type (float,
str, etc.) of a variable is not fixed until a value is assigned at
runtime. This is convenient, but means type errors only surface when the
offending code is actually executed. Consider this simple function:
With numeric arguments it works as expected(1):
- In Python, dividing two integers always creates a float!
Passing strings instead:
>>> division("hello", "world")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 2, in division
TypeError: unsupported operand type(s) for /: 'str' and 'str'
>>>
There is nothing wrong syntactically — Python accepts the call without
complaint. The error only appears at runtime, when the / operator finds
itself applied to strings. To catch these issues earlier, Python allows adding
type annotations:
def division(a: float, b: float) -> float: # (1)!
return a / b
division("a", "b") # <- produces editor warning
division(1, 2) # <- OK
- The return type annotation matters too — if the output of
divisionis used elsewhere, downstream code knows what type to expect.
Type hints are not enforced — Python will still attempt to run annotated code with the wrong argument types. Their value is elsewhere: as documentation, and as input to tools like mypy or a type-aware editor that can flag errors before the code runs (1).
- typically with squiggly red underlines and error messages on hover.
Duck typing
Type hints describe what something is, but sometimes it is more useful to consider what something does. This is duck typing: if an object has the right attributes and methods, it can be used regardless of its actual type — the same way something can be considered a duck if it walks and quacks like one. The following example defines two classes and a function that accepts either, not by checking the type, but by checking the behaviour:
class Duck: # (1)!
def quack(self):
return "quack, quack!"
def waddle(self):
return "waddle, waddle!"
class Human: # (2)!
def quack(self):
return "quack, quack!"
def waddle(self):
return "waddle, waddle!"
def is_a_duck(thing): # (3)!
try:
thing.quack()
thing.waddle()
print("I must be a duck!")
except AttributeError:
print("I'm unable to walk and talk like a duck.")
- Two methods:
quackandwaddle. - A human can also quack and waddle.
- Accepts anything that can
quackandwaddle— not justDuckinstances.
>>> from snippets.duck import Duck, Human, is_a_duck
>>> donald = Duck()
>>> joe = Human()
>>> is_a_duck(donald)
I must be a duck!
>>> is_a_duck(joe)
I must be a duck!
>>>
is_a_duck never checks the type of its argument — only whether it has
quack and waddle. Sometimes that is exactly what you want.
Duck typing in the wild.
A real-world example where duck typing is used in Python, is in the
built-in len() function:
>>> my_string = "hello world"
>>> len(my_string) # the len() function works with a string (1)!
11
>>> my_list = [1, 2, 3]
>>> len(my_list) # and with a list (2)!
3
>>> my_int = 42
>>> len(my_int) # but not with an integer (3)!
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: object of type 'int' has no len()
>>>
- The
len()function works with a string, where it returns the number of characters in the string ... - ... and with a list, where it returns the number of items in the list.
- But not with an integer.
Behind the scenes, len() doesn't look for valid input
types, but rather if the object it is given as input possesses the
__len__() attribute:
Without a type signature, is_a_duck() is fragile — changes to Duck
or Human that break the function would only surface at runtime. Adding one
helps:
Safer, but now tightly coupled to both Duck and Human. Adding a third
compatible class means updating the function, and changes to either class
become potential edits everywhere it is used. Type hints used this way scale
poorly. Protocol classes offer a better approach.
Structural subtyping (static duck typing)
A Protocol class defines a structure — the attributes and
methods a conforming class must provide — without requiring inheritance. Any
class that matches is implicitly a subtype, checked statically by mypy or your
editor rather than at runtime. This is
structural subtyping:
duck typing, but with static checking. Revisiting the duck example with an
additional Robot class:
from typing import Protocol
class Ducklike(Protocol): # (1)!
def quack(self) -> str: ... # (2)!
def waddle(self) -> str: ...
class Duck: # (3)!
def quack(self) -> str:
return "quack, quack!"
def waddle(self) -> str:
return "waddle, waddle!"
class Human: # (4)!
def quack(self) -> str:
return "quack, quack!"
def waddle(self) -> str:
return "waddle, waddle!"
def dance(self) -> str:
return "shaking those hips!"
class Robot: # (5)!
def quack(self) -> bytes:
return bytes("beep, quack!", encoding="utf-8")
def waddle(self) -> str:
return "waddle, waddle!"
def is_a_duck(thing: Ducklike) -> None: # (6)!
try:
thing.quack()
thing.waddle()
print("I must be a duck!")
except AttributeError:
print("I'm unable to walk and talk like a duck.")
- Defines the
Ducklikeprotocol — any class with matchingquackandwaddlesignatures satisfies it, no inheritance required. - Ellipses (
...) are preferred overpasshere. - Implicitly
Ducklike— the structure matches, so no explicit declaration is needed. - Also
Ducklikedespite having an extradancemethod — the protocol only requires what it defines. Robot.quack()returnsbytes, notstr— close, but notDucklike.- Typed against the protocol rather than specific classes —
Robotwill be flagged by mypy or your editor, whileDuckandHumanpass.
The runtime behaviour is the same as before:
>>> from snippets.duck_protocol import Duck, Human, Robot, is_a_duck
>>>
>>> donald = Duck()
>>> joe = Human()
>>> robert = Robot()
>>> is_a_duck(donald)
I must be a duck!
>>> is_a_duck(joe)
I must be a duck!
>>> is_a_duck(robert)
I must be a duck!
>>>
Python does not enforce type hints at runtime, so all three calls succeed.
The difference only shows up statically — Robot.quack() returns bytes
instead of str, which does not satisfy the Ducklike signature. A type
checker will flag this before the code runs:
>>> from snippets.duck_protocol import Ducklike
>>> from typing import get_type_hints
>>>
>>> get_type_hints(Ducklike.quack) == get_type_hints(robert.quack)
False
>>> get_type_hints(Ducklike.quack) == get_type_hints(donald.quack)
True
>>> get_type_hints(Ducklike.quack) == get_type_hints(joe.quack)
True
>>>
Two properties of Protocol classes matter here:
- A function typed against a protocol is decoupled from any particular implementation — it works with any class that satisfies the structure, including ones written long after the function was.
- Conforming classes must match all protocol attributes, but may have others.
like_a_duck()works withDuckandHumandespite methods it never touches.
Protocol classes are typically much simpler than the
classes they describe(1) — they contain only what a function needs to know.
Think of them as a contract: a class that satisfies a protocol guarantees that
interface regardless of what else it does, and functions written against it are
free to ignore everything else. In pysmo, these contracts are the types we
will explore in the next section.
- Unlike a regular class, a
Protocolclass contains only structural information — no data, no implementation.
Next steps
- Learn more about type hinting and static analysis with mypy.
- If you are not already using an editor that checks your code as you write, now is a good time to switch.
- Continue to the next chapter and install pysmo.