Python type hints: mixin classes

In Python, a mixin class is a class that is not intended to be used directly, but instead “mixed in” to other classes through multiple inheritance. Mixins are not really a language feature but more of a conventional pattern allowed by Python’s multiple inheritance rules. Unfortunately, adding type hints to mixin classes can be tricky because they implicitly require subclasses to fit a certain shape.
For example, take this mixin class that adds an extra method, “shout”, mixed into a dict subclass:
class ShoutMixin:
def shout(self) -> str:
return f"{str(self).upper()}!!!"
class ShoutyDict(ShoutMixin, dict[str, str]):
pass
ShoutyDict can be used like:
In [1]: from example import ShoutyDict
In [2]: d = ShoutyDict({"bubbles": "rainbow"})
In [3]: d.shout()
Out[3]: "{'BUBBLES': 'RAINBOW'}!!!"
ShoutMixin is a rather minimal mixin class: it only augments the class with a new method, and it doesn’t rely on any attributes or methods from the base class. It only requires that str(self) works, which is true for all Python objects. Writing types for ShoutMixin is thus little work: only the str return type for shout() is required.
Most mixin classes are more complex and require either a certain base class or a base protocol. Let’s look at both cases in turn.
Mixins that require a base class
Typically, a mixin class is designed to be used with one specific base class. In such cases, it’s best to simply declare the mixin as a subclass of the base class, making it a regular subclass, even if it’s still not intended to be used directly. Using the base class lets type checkers find the types of inherited attributes and methods, and multiple inheritance still allows subclasses to combine the “mixin” with other base classes.
For example, say we combine a base class called Unicorn with a mixin class called SparklesMixin:
from typing import Any
class Unicorn:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
class SparklesMixin:
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.name = f"✨ {self.name} ✨"
class SparklyUnicorn(SparklesMixin, Unicorn):
pass
This works at runtime just fine:
In [1]: from example import SparklyUnicorn
In [2]: unicorn = SparklyUnicorn("Jimbob", 172)
In [3]: unicorn.name
Out[3]: '✨ Jimbob ✨'
However, Mypy will find fault with SparklesMixin because it can’t be sure that self.name will always exist and be a string:
$ mypy example.py
example.py:13: error: Cannot determine type of "name" [has-type]
example.py:16: error: Cannot determine type of "name" in base class "SparklesMixin" [misc]
Found 2 errors in 1 file (checked 1 source file)
We can quickly fix the issue by adding Unicorn as a base class of SparklesMixin:
-class SparklesMixin:
+class SparklesMixin(Unicorn):
That change fixes the errors:
$ mypy example.py
Success: no issues found in 1 source file
We actually can go further and drop SparklesMixin entirely, leaving just SparklyUnicorn:
-class SparklesMixin(Unicorn):
+class SparklyUnicorn(Unicorn):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.name = f"✨ {self.name} ✨"
-
-
- class SparklyUnicorn(SparklesMixin, Unicorn):
- pass
SparklyUnicorn can still be used as a “mixin” through regular multiple inheritance:
class GrumpyUnicorn(Unicorn):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.name = f"😠 {self.name} 😠"
class GrumpySparklyUnicorn(GrumpyUnicorn, SparklyUnicorn):
pass
The subclasses stack up on top of the base class, Unicorn, as visible in the method resolution order:
In [1]: from example import GrumpySparklyUnicorn
In [2]: GrumpySparklyUnicorn.__mro__
Out[2]:
(example.GrumpySparklyUnicorn,
example.GrumpyUnicorn,
example.SparklyUnicorn,
example.Unicorn,
object)
And the behaviours stack too:
In [3]: unicorn = GrumpySparklyUnicorn("Jimbob", 172)
In [4]: unicorn.name
Out[4]: '😠 ✨ Jimbob ✨ 😠'
Mypy continues to pass as well, because the name attribute is always detectable in the “mixin” classes.
Mixins that require a protocol
In rare cases, mixin classes require a specific protocol rather than a specific base class. This allows them to work with generic objects that fulfil some interface. In these cases, correct types require typing.Protocol, as briefly covered in the Mypy documentation. (I previously covered using typing.Protocol for duck-typing.)
For example, say we have two base classes augmented by a mixin:
class Unicorn:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
class Vase:
def __init__(self, name: str, capacity: int) -> None:
self.name = name
self.capacity = capacity
class ShoutNameMixin:
def shout_name(self) -> str:
return f"{self.name.upper()}!!!"
class ShoutingUnicorn(Unicorn, ShoutNameMixin):
pass
class ShoutingVase(Vase, ShoutNameMixin):
pass
These classes will work at runtime:
In [1]: from example import *
In [2]: ShoutingUnicorn("gertrude", 201).shout_name()
Out[2]: 'GERTRUDE!!!'
In [3]: ShoutingVase("va", 16).shout_name()
Out[3]: 'VA!!!'
But Mypy will fail to type check the use of name in ShoutNameMixin:
$ mypy example.py
example.py:15: error: "ShoutNameMixin" has no attribute "name" [attr-defined]
Found 1 error in 1 file (checked 1 source file)
This error correctly determines that instances of ShoutNameMixin or its subclasses are not guaranteed to have a name attribute.
The fix is to declare a protocol that requires the name attribute, and then use that type for self in methods in ShoutNameMixin:
from typing import Protocol
class Named(Protocol):
name: str
class ShoutNameMixin:
def shout_name(self: Named) -> str:
return f"{self.name.upper()}!!!"
That change will appease Mypy:
$ mypy example.py
Success: no issues found in 1 source file
self protocols are checked at call sites
Because the protocol is applied to self in the method, Mypy will check for conformance only at call sites. Say we changed the attribute name in Unicorn:
class Unicorn:
- def __init__(self, name: str, age: int) -> None:
+ def __init__(self, nom: str, age: int) -> None:
- self.name = name
+ self.nom = nom
self.age = age
Mypy would not detect any issues with the subclassing:
class ShoutingUnicorn(Unicorn, ShoutNameMixin):
pass
But for call sites, like:
ShoutingUnicorn("Gertrude", 201).shout_name()
Mypy will report:
$ mypy example.py example.py:33: error: Invalid self argument "ShoutingUnicorn" to attribute function "shout_name" with type "Callable[[Named], str]" [misc] Found 1 error in 1 file (checked 1 source file)
That’s not the clearest message, but at least it somewhat states that self doesn’t conform to Named.
Avoid declaring attributes in mixin classes
One incorrect fix that I’ve seen for mixin classes involves declaring required attributes in the mixin class, like:
class ShoutNameMixin:
+ name: str # Don’t do this!
def shout_name(self) -> str:
return f"{self.name.upper()}!!!"
This change asserts to Mypy that the attribute exists, without any guarantees. Unfortunately, this allows incorrect usage like:
ShoutNameMixin().shout_name() # fails at runtime, but Mypy is happy
So, avoid this technique.
😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸
One summary email a week, no spam, I pinky promise.
Related posts: