You know that frustrating moment when your code seems perfect, but Python’s type checker (mypy) stubbornly insists there’s a problem? My first instinct is always, “This has got to be a mistake!”
Recently, I hit this exact scenario while building a beverage-management app. Mypy threw this baffling error:
|
|
Initially, I thought, “But Coke is a Soda! Why can’t I use a Coke can wherever a Soda can is expected?”
Turns out, mypy was protecting me from a runtime disaster I couldn’t see coming.
The “Sprite in a Coke Can” Disaster
To understand what mypy was preventing, imagine this real-world scenario:
You have a can specifically labeled “Coke.” You hand it to someone at a party, and they innocently fill it with Sprite (after all, Sprite is also a soda, right?). Later, you confidently take a sip expecting that familiar cola taste, and suddenly—surprise!—you’re tasting lemon-lime. Your expectations are completely violated!
This is exactly the disaster Python’s type system prevents in your code.
Here’s how this scenario translates to Python:
|
|
Mypy blocks this because if it allowed the substitution, your specialized Coke can would get contaminated with Sprite, violating the type contract.
Why Can’t We Treat TinCan[Coke]
as TinCan[Soda]
?
You might think: “Since every Coke is a Soda, shouldn’t every TinCan[Coke]
be a TinCan[Soda]
?”
The answer is no, and here’s why:
TinCan[Soda]
promises to accept any soda via itsfill
methodTinCan[Coke]
promises to accept only Coke- If we treat
TinCan[Coke]
asTinCan[Soda]
, we’d violate promise #2
This relationship between generic types is called variance, and understanding it is crucial for type safety.
The Secret Life of Containers: Variance Explained
The key insight is that container substitutability depends on whether the container allows reading, writing, or both. Python categorizes these patterns:
🥤 Covariant Containers: Read-Only (Safe to Go Specific → General)
Imagine a sealed can—you can drink from it but never refill it:
|
|
Real-world examples:
Sequence[T]
,Iterable[T]
,Iterator[T]
are all covariant- Function return types are covariant
🪣 Contravariant Containers: Write-Only (Safe to Go General → Specific)
Now imagine a disposal can—you can only put things in, never take them out:
|
|
Real-world examples:
- Function parameter types are contravariant
Callable[[T], None]
is contravariant inT
⚖️ Invariant Containers: Read-and-Write (No Safe Substitutions)
When a container supports both reading and writing (like our original TinCan
), it’s invariant:
|
|
Real-world examples:
list[T]
,dict[K, V]
,set[T]
are all invariant- Most mutable containers are invariant
Quick Reference: When to Use Each Variance
Variance | When to Use | Type Parameter | Example |
---|---|---|---|
Covariant | Read-only operations | TypeVar("T", covariant=True) |
Producers, getters, iterators |
Contravariant | Write-only operations | TypeVar("T", contravariant=True) |
Consumers, setters, handlers |
Invariant | Read-write operations | TypeVar("T") |
Mutable containers |
Fixing Our Original Problem
So how do we fix our party drinks scenario? Here are three approaches:
Option 1: Use a Protocol for Read-Only Access
|
|
Option 2: Be Explicit About Types
|
|
Option 3: Use Union Types for Flexibility
|
|
Common Variance Pitfalls and How to Avoid Them
Pitfall 1: Assuming List Substitutability
|
|
Fix: Use Sequence
for read-only access:
|
|
Pitfall 2: Incorrect Variance Declarations
|
|
Fix: Match variance to actual usage patterns.
My Aha Moment
My “Sprite in the Coke can” moment transformed how I think about type safety. Instead of fighting mypy’s strictness, I now see it as a protective friend preventing subtle runtime disasters.
Now, whenever I see a variance error, I ask myself:
-
What operations does this container support?
- Only reading → Make it covariant
- Only writing → Make it contravariant
- Both → Keep it invariant
-
What substitutions am I trying to make?
- Specific → General? Need covariance
- General → Specific? Need contravariance
- Either direction? You’re out of luck with invariant types
-
Can I redesign to avoid the issue?
- Split read/write interfaces
- Use protocols for flexibility
- Be more specific about types
Variance in Python’s Standard Library
Understanding variance helps you use Python’s built-in types correctly:
|
|
Conclusion
Variance might seem like an obscure type theory concept, but it’s actually about preventing real bugs. The “Sprite in a Coke can” problem isn’t just theoretical—it represents actual runtime errors that variance rules prevent.
Next time mypy complains about variance:
- Don’t fight it—understand what it’s protecting you from
- Think about whether your container is read-only, write-only, or both
- Choose the appropriate variance or redesign your interface
Remember: Those type errors that seem annoying today are the runtime crashes you’re avoiding tomorrow.
Have you encountered variance-related issues in Python? How did you solve them? Share your stories in the comments!
Found this helpful? Consider sharing it with your team or bookmarking it for the next time mypy seems to be “wrong” about your perfectly reasonable code.