Alex Lin Wang 王帅

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!