TypeVar Contravariance
TypeVar contravariance helps resolve type checking issues in class
hierarchies, particularly for consumer functions that take parameters of generic
types.
Before diving into contravariance, we need to define three fundamental terms in type theory and subtyping:
- Covariance: A is a subtype of B → Container[A] is a subtype of Container[B].
- Contravariance: A is a subtype of B → Container[B] is a subtype of Container[A].
- Invariance: A is a subtype of B → Container[A] is distinct from Container[B].
From object-oriented programming, a subtype means that whenever you have something that expects type A, if B is a subtype of A, you can replace A with B and everything should still work correctly. This is the essence of the Liskov Substitution Principle.
The Problem: Type Errors in Class Hierarchies
Let's imagine you want to create a class hierarchy with logging methods for different types of posts. The highest level is a general post, then there are subtypes like VideoPost and ImagePost.
class Post:
def __init__(self, content: str) -> None:
self.content = content
class VideoPost(Post):
def __init__(self, content: str, duration: int) -> None:
super().__init__(content)
self.duration = duration
class ImagePost(Post):
def __init__(self, content: str, image_url: str) -> None:
super().__init__(content)
self.image_url = image_url
Now, if you write a logger hierarchy, you'll encounter a type error:
class PostLogger:
def log(self, post: Post) -> None:
print(f"Logging post: {post.content}")
class VideoLogger(PostLogger):
def log(self, post: VideoPost) -> None: # Type error!
print(f"Logging video: {post.content} (duration: {post.duration}s)")
class ImageLogger(PostLogger):
def log(self, post: ImagePost) -> None: # Type error!
print(f"Logging image: {post.content} (image: {post.image_url}))")
Why Does This Fail?
The issue arises because of contravariance in method parameters. When you override a method, the input parameters must be contravariant—meaning you can only accept more general types, not more specific ones.
Think about it: VideoLogger cannot only accept
VideoPost
because the base class PostLogger accepts any
Post. Therefore, ImagePost should also be acceptable
to maintain the substitutability principle.
However, by default, type variables are invariant, which means Logger[VideoPost]
and Logger[Post] are considered completely different types.
The Solution: Contravariant TypeVar
We can use TypeVar with contravariance to define the proper relationship.
This aligns with the Liskov Substitution Principle and works correctly since
our functions are consumers of the generic type.
from typing import TypeVar, Generic
T = TypeVar("T", bound=Post, contravariant=True) # bound is the most-super type
class Logger(Generic[T]):
def log(self, post: T) -> None:
pass
class PostLogger(Logger[Post]):
def log(self, post: Post) -> None:
print(f"Logging post: {post.content}")
class VideoLogger(Logger[VideoPost]):
def log(self, post: VideoPost) -> None:
print(f"Logging video: {post.content} (duration: {post.duration}s)")
class ImageLogger(Logger[ImagePost]):
def log(self, post: ImagePost) -> None:
print(f"Logging image: {post.content} (image: {post.image_url}))")
Understanding the Contravariant Relationship
Using our initial analogy: Post is B, and
VideoPost/ImagePost
are A, where the latter are subtypes of the former.
We know that Logger[Post] should be able to accept any
VideoPost
or
ImagePost, because any information available in a
Post type should automatically be available in its subtypes.
Thus, Logger[Post] is a subtype of
Logger[VideoPost]
because if you needed a Logger[VideoPost], I could give you a
Logger[Post], which accepts both Post and
VideoPost, and it would be safe.
Here's how this works in practice:
def process_video_logger(logger: Logger[VideoPost]) -> None:
video = VideoPost("Check out this video!", 120)
logger.log(video)
# This works because Logger[Post] is a subtype of Logger[VideoPost]
post_logger = PostLogger()
process_video_logger(post_logger) # ✓ Type checker approves!
# This also works
video_logger = VideoLogger()
process_video_logger(video_logger) # ✓ Type checker approves!
When to Use Contravariant TypeVar
Use contravariant TypeVar when:
- You have a function or method that takes a parameter of a generic type.
- You want it to accept arguments of a more general type.
- Your class/function is a consumer of the generic type.
- You need to maintain proper subtyping relationships in inheritance hierarchies.
Questions or feedback? Feel free to reach out!