Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Proposal: Trait for strict, non-widening type bounds (solving LSP issues in binary ops) #2143

kamalfarahani started this conversation in General
Discussion options

Abstract

I propose introducing a new construct named Trait. Unlike an ABC or Protocol, a Trait acts as a strict constraint that prevents the type solver from "widening" a TypeVar to the base type. This specifically solves the long-standing issue of defining binary operations (like Semigroups) on subclasses without violating the Liskov Substitution Principle (LSP).

The Problem: The "Binary Operation" Paradox

In current Python typing, if we want to define a generic binary operation, we often run into a conflict between type safety and LSP.

from abc import ABC, abstractmethod
from typing import Self
class Semigroup(ABC):
 @abstractmethod
 def op(self, other: Self) -> Self: ...
class IntSemigroup(Semigroup):
 def __init__(self, value: int) -> None:
 self.value = value
 def op(self, other: "IntSemigroup") -> "IntSemigroup":
 # ⚠️ This is technically an LSP violation. 
 # Semigroup.op promised to accept ANY Semigroup, 
 # but IntSemigroup only accepts IntSemigroup.
 return IntSemigroup(self.value + other.value)
class StringSemigroup(Semigroup):
 def __init__(self, value: str) -> None:
 self.value = value
 def op(self, other: "StringSemigroup") -> "StringSemigroup":
 return StringSemigroup(self.value + other.value)
def combine[T: Semigroup](a: T, b: T) -> T:
 return a.op(b)
# ❌ The Runtime Failure
combine(IntSemigroup(1), StringSemigroup("2")) 

Why this fails today:

  1. Widen-to-Bound: When combine is called with an Int and a String, the type checker sees both satisfy Semigroup. It resolves T to Semigroup.
  2. LSP Conflict: To be type-safe, Semigroup.op must accept any other Semigroup. But for concrete logic (like addition), it must only accept its own type.

The Solution: Trait

If we define Semigroup as a Trait, we change the rules of engagement. A Trait is not a "parent type" that objects can be widened to; it is a requirement that a concrete type must satisfy.

from typing import Trait # Proposed new construct
class Semigroup(Trait):
 @abstractmethod
 def op(self, other: Self) -> Self: ...
def combine[T: Semigroup](a: T, b: T) -> T:
 return a.op(b)
# HOW IT WORKS:
# 1. T can NEVER resolve to "Semigroup" itself. It must be a concrete leaf type.
# 2. Since T must be a single concrete type, the solver attempts to unify 
# IntSemigroup and StringSemigroup.
# 3. Unification fails because they are different concrete types.
# ✅ Type Checker Error: 
# "Cannot unify 'IntSemigroup' and 'StringSemigroup' for strict Trait-bound 'T'"
combine(IntSemigroup(1), StringSemigroup("2")) 

Why this is better

  1. LSP Preservation: Since Semigroup is a Trait and not a base class, we are not saying "Int is a Semigroup" in a way that requires substitution. We are saying "Int implements the Semigroup interface."
  2. No More Runtime TypeError: The type checker catches "mixed implementation" calls that currently pass when using ABC bounds.
  3. Rust-like Traits: It brings the safety of Rust traits or Haskell typeclasses to Python’s generic functions.

Proposed Semantics

  • Non-Reifiable: You cannot instantiate a Trait, and a TypeVar bound by a Trait cannot resolve to that Trait.
  • Strict Unification: When a TypeVar is bound by a Trait, all arguments associated with that TypeVar must unify to the exact same concrete type.

Note: "Protocols also suffer from the widening issue. If I have def f[T: MyProtocol](a: T, b: T), the checker will still happily resolve T to the Protocol itself if I pass two different objects that both happen to implement it. I am looking for a way to force concrete type unification."

You must be logged in to vote

Replies: 1 comment

Comment options

Since the new thing being expressed here is some additional constraints on typevar solving, I think it would be preferable if the new type system concept introduced were more narrowly targeted to typevar solving -- something more like https://discuss.python.org/t/support-function-with-multiple-generic-parameters-same-type/57931/6

Here it feels like we are introducing yet another variant of Protocols or ABCs (a type system feature with large surface area), just in order to (effectively) tweak the semantics of a generic protocol.

You must be logged in to vote
0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet

AltStyle によって変換されたページ (->オリジナル) /