Skip to content

SIP-67 - Improve strictEquality feature for better compatibility with existing code bases#97

Open
mberndt123 wants to merge 31 commits intoscala:mainfrom
mberndt123:strictEquality-pattern-matching
Open

SIP-67 - Improve strictEquality feature for better compatibility with existing code bases#97
mberndt123 wants to merge 31 commits intoscala:mainfrom
mberndt123:strictEquality-pattern-matching

Conversation

@mberndt123
Copy link
Copy Markdown

Hi there,

I'd like to use the strictEquality feature for the improved type safety it provides, but currently find it too inconvenient to use due to an unfortunate interaction with pattern matching. This SIP is my attempt to fix that.
There have been no comments in the Pre-SIP thread for the past two weeks, and it's a very small (though impactful) change to the language, so I felt it was time to submit it.

Best regards
Matthias

@kyouko-taiga kyouko-taiga changed the title improve strictEquality feature for better compatibility with existing code bases SIP-67 - Improve strictEquality feature for better compatibility with existing code bases Nov 15, 2024
@soronpo
Copy link
Copy Markdown
Contributor

soronpo commented Nov 25, 2024

Thank you for the proposal. Here is the feedback from the SIP Committee:

  • We would like to see a different proposal that special-cases enumerations in both pattern matching and equality operations so that enumerations are considered to have a CanEqual derivation and the compiler would pick up the default equality if no explicit derives CanEqual is given. This will take care of old libraries that do not have CanEqual derivation.
  • In general, we do not think its good to have a different behavior between == and pattern match equality. Do you have such a use-case?

@mberndt123
Copy link
Copy Markdown
Author

mberndt123 commented Nov 25, 2024

Hey @soronpo,

I'm sorry, I'm afraid I can't do that.

Please consider the following enum:

enum Foo:
  case Bar
  case Baz(f: Int => Int)

I'm going to contend that it shouldn't ever be allowed to compare two Foo values with == because that would require us to determine whether two functions are the same, which isn't possible in a useful way.
Scala agrees with me on this already, because this type is isomorphic to Option[Int => Int], and that type can't be compared with ==, despite the fact that a CanEqual instance for Option is available by default:

scala> import scala.language.strictEquality

scala> Option.empty[Int => Int] == Option.empty[Int => Int]
-- [E172] Type Error: ----------------------------------------------------------
1 |Option.empty[Int => Int] == Option.empty[Int => Int]
  |^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |Values of types Option[Int => Int] and Option[Int => Int] cannot be compared with == or !=.

This is not a bug, this is a feature.

Now you might say, OK, then maybe we can make the CanEqual instance available only when all the fields in the enum have a CanEqual instance? I. e. make CanEqual[Foo, Foo] available only when CanEqual[Int => Int, Int => Int] is available? We can simulate this:

scala> enum Foo:
     |   case Bar
     |   case Baz(f: Int => Int)
     | object Foo:
     |   given (using CanEqual[Int => Int, Int => Int]): CanEqual[Foo, Foo] =
     |     CanEqual.derived
     | 
// defined class Foo
// defined object Foo

Alas, while this does prevent nonsensical comparisons with ==, it also fails to achieve the whole point of this proposal, which is to allow pattern matching:

scala> def bla(f: Foo) match
     |   case Foo.Bar => 0
     |   case Foo.Baz(f) => f(0)
     | 
-- [E172] Type Error: ----------------------------------------------------------
2 |  case Foo.Bar => 0
  |       ^^^^^^^
  |Values of types Foo and Foo cannot be compared with == or !=.

(insert sad trombone sound here)

Now you might ask: How come it works just right for Option? You cannot compare two Option[Int => Int] objects with ==, but it is possible to perform pattern matching on an Option[Int => Int]:

scala> Option.empty[Int => Int] match
     |   case None => 0
     |   case Some(f) => f(0)
     | 
val res0: Int = 0

That seems like the sweet spot! The reason this works is that None is of type Option[Nothing], hence this pattern match doesn't require a CanEqual[Option[Int => Int], Option[Int => Int]] but a CanEqual[Option[Int => Int], Option[Nothing]], which is available.
However this doesn't carry over to enum Foo: it doesn't have any type parameters, hence it's not possible to differentiate the empty Bar case from the non-empty Baz one on the type level.

The conclusion from all of this is that it's impossible to make this work correctly by providing a magic CanEqual instance. enum Foo is the proof for this: you either provide the instance, which makes pattern matching work but allows for == comparisons that don't make sense, or you don't, in which case you can't do pattern matching. Whatever the solution to this problem is, this is not it.

@soronpo
Copy link
Copy Markdown
Contributor

soronpo commented Nov 25, 2024

Please consider the following enum:

enum Foo:
  case Bar
  case Baz(f: Int => Int)

I would not consider this to be a valid use of enum. Have you seen this type of code in the wild?

@jducoeur
Copy link
Copy Markdown

Have you seen this type of code in the wild?

Not sure offhand, but it seems like it would be unsurprising when constructing a DSL interpreter. (Where the enum is a kind of expression, and the leaf is an instantiation of one expression type.)

The interpreter for my own QL language is still on Scala 2, but I could see myself trying to build it along those lines if it was Scala 3, so I don't think that's just a strawman.

@soronpo
Copy link
Copy Markdown
Contributor

soronpo commented Nov 25, 2024

Not sure offhand, but it seems like it would be unsurprising when constructing a DSL interpreter.

Still I'm not sure enum is the correct way to do it in Scala 3. It's not meant as a replacement for all kinds of case class hierarchies.

@eugengarkusha
Copy link
Copy Markdown

eugengarkusha commented Nov 25, 2024

I would not consider this to be a valid use of enum. Have you seen this type of code in the wild?

I have seen ADTs with functions inside
(Hope its ok to consider enum as a nicer syntax for ADT)

@KristianAN
Copy link
Copy Markdown

I would not consider this to be a valid use of enum. Have you seen this type of code in the wild?

I have seen ADTs with functions inside (Hope its ok to consider enum as a nicer syntax for ADT)

From the Scala 3 book.

Algebraic Data Types (ADTs) can be created with the enum construct, so we’ll briefly review enumerations before looking at ADTs.

Personally I have almost exclusively written Scala 3 and would use enums for this kind of ADT (with a function). If that is not the correct way to do it, then the correct way must be elusive.

@jducoeur
Copy link
Copy Markdown

Yeah, agreed. My understanding of the enum feature was that it was more or less exactly to reify the pattern that the community has settled on for ADTs over the years. (With enumerations per se being sort of the degenerate case, but far from the whole story.)

Saying that only some ADTs count, and other reasonably well-formed ones don't seems kind of un-Scala to me. A key element of Scala is that functions are values; intuitively, I would expect them to work here like any other value type.

@SystemFw
Copy link
Copy Markdown

SystemFw commented Nov 26, 2024

I have to agree with @jducoeur , what's the actual argument against functions in enum?
enum already goes quite some way into trying to capture some of the more complex ADT patterns that have little to do with enums, for example allowing extends to encode GADTs, having a value in there that happens to be a function seems like a lot more "normal" in comparison.

The GADT mention is not accidental, the main use case for that is encoding commands:

enum Cmd[A] { 
  case Read() extends Cmd[String]
  case Write extends Cmd[Unit]
}

and command-like GADTs will often have functions in them to allow sequencing:

enum Cmd[A] { 
  case Read() extends Cmd[String]
  case Write extends Cmd[Unit]
  case FlatMap[O, A](fa: Cmd[O], f: O => Cmd[A]) extends Cmd[A]
}

I feel like the argument that enum isn't meant for this would be a lot more stronger if extends wasn't allowed at all

@soronpo
Copy link
Copy Markdown
Contributor

soronpo commented Nov 26, 2024

Let me clarify. If you want function arguments that should not be part of the pattern match, then these should come as a second argument block of the case, IMO.

enum Cmd[A]:
  case Read() extends Cmd[String]
  case Write extends Cmd[Unit]
  case FlatMap[O, A](fa: Cmd[O])(val f: O => Cmd[A]) extends Cmd[A]

If you want them to be part of the pattern match, you put them in the first block where they have same equality treatment like all the other arguments.

@JD557
Copy link
Copy Markdown

JD557 commented Nov 26, 2024

I admit that I never played with strict equality, so this might not make much sense, but:

Now you might say, OK, then maybe we can make the CanEqual instance available only when all the fields in the enum have a CanEqual instance? I. e. make CanEqual[Foo, Foo] available only when CanEqual[Int => Int, Int => Int] is available? We can simulate this:

Do we need a CanEqual[Foo, Foo] for the pattern match? Wouldn't a CanEqual[Foo, Foo.Bar.type] suffice?

Now, from what I can tell based on some quick tests, the compiler really wants a CanEqual[Foo, Foo] for pattern matching, but maybe this could be changed?

@mberndt123
Copy link
Copy Markdown
Author

mberndt123 commented Nov 26, 2024

Hi @JD557,

Do we need a CanEqual[Foo, Foo] for the pattern match? Wouldn't a CanEqual[Foo, Foo.Bar.type] suffice?

Now, from what I can tell based on some quick tests, the compiler really wants a CanEqual[Foo, Foo] for pattern matching, but maybe this could be changed?

Yes, I had this in mind and I was going to propose it – you beat me to it. It can't be done with just a magic CanEqual instance, but if we slightly tweak the behaviour pattern matching under strictEquality I think it should work.

I. e.

def bla(foo: Foo) =
  foo match
    case Foo.Bar => 0 // requires CanEqual[Foo.Bar.type, Foo]
    case Foo.Baz(f) => f(0) // uses unapply

Then all we would need is a magic CanEqual instance that spawns the required CanEqual. That seems like a workable approach.

@mberndt123
Copy link
Copy Markdown
Author

mberndt123 commented Nov 26, 2024

Let me clarify. If you want function arguments that should not be part of the pattern match, then these should come as a second argument block of the case, IMO.

[…]

If you want them to be part of the pattern match, you put them in the first block where they have same equality treatment like all the other arguments.

First of all, I'd like to address the question of "in the wild" examples of functions within ADTs. This is definitely something that people do, e. g.
https://github.com/typelevel/cats/blob/1cc04eca9f2bc934c869a7c5054b15f6702866fb/free/src/main/scala/cats/free/Free.scala#L219
https://github.com/typelevel/cats-effect/blob/eb918fa59f85543278eae3506fda84ccea68ad7c/core/shared/src/main/scala/cats/effect/IO.scala#L2235

I think this is perfectly good code. It should be possible to use enum for these types, and it is today – strictEquality shouldn't break that. I also see no reason to compel people to rewrite this to a style where the function goes in the second parameter list. In fact, I think it is worse that way, because == is supposed to tell you whether two things are the same – but if you just ignore the function, you're simply going to get a broken equals method that says that two things are the same even though they aren't. This is something that we shouldn't encourage. The better option is to allow the subset of equality comparisons that people are likely to need and that we know we can perform correctly – i. e. comparisons to the singleton cases – and prevent all other equality tests at compile time.

@mberndt123
Copy link
Copy Markdown
Author

I've pushed a new revision that is based on a minor change to enum pattern matching and a magic CanEqual instance. @soronpo please let me know what you think

@mberndt123
Copy link
Copy Markdown
Author

Thanks @soronpo and @SethTisue.

I've added a handful of examples, I hope that clarifies things

@bracevac
Copy link
Copy Markdown
Contributor

Please note that the SIP template has changed:

  1. The SIP number goes into a number: NN YAML header, and titles should not be prefixed with SIP NN -
  2. The file name should be prefixed with the SIP number, padded to three digits.

Thanks!

@nilskp
Copy link
Copy Markdown

nilskp commented Feb 5, 2026

New to this SIP. I always thought Scala would implement strict equality akin to Javascript, with a triple equals ===. I'm sure this has been discussed, but perhaps someone can point me to where?

@soronpo
Copy link
Copy Markdown
Contributor

soronpo commented Feb 5, 2026

New to this SIP. I always thought Scala would implement strict equality akin to Javascript, with a triple equals ===. I'm sure this has been discussed, but perhaps someone can point me to where?

Such discussions would usually occur on https://contributors.scala-lang.org/ but I don't remember such discussion.

@jducoeur
Copy link
Copy Markdown

jducoeur commented Feb 5, 2026

New to this SIP. I always thought Scala would implement strict equality akin to Javascript, with a triple equals ===. I'm sure this has been discussed, but perhaps someone can point me to where?

Note that === is implemented in some libraries (notably Scalactic) -- for those who want to opt into that, it's long been available.

Strict equality is much further-reaching than that, though, intentionally -- it's not just opt-in on an ad hoc call-site basis, it prevents you from handling equality in an ad-hoc way, and enforces some discipline instead. (Still deciding how much I like it in practice, but I appreciate the principle.)

@mberndt123 mberndt123 force-pushed the strictEquality-pattern-matching branch from e23b4e9 to 8216075 Compare April 10, 2026 22:05
@mberndt123
Copy link
Copy Markdown
Author

mberndt123 commented Apr 10, 2026

Hi there,

I've updated this SIP to incorporate feedback that I've gathered. I’d appreciate it if the committee could take another look!
@soronpo

@SethTisue
Copy link
Copy Markdown
Member

@soronpo @Gedochao it's a bit unfortunate that we skipped a SIP meeting or two, as this could very likely have been approved by the committee in time for 3.9.0-RC1

but since the user feedback has been uniformly positive, and since it only affects an experimental feature, perhaps it's actually not too late for 3.9?

@soronpo
Copy link
Copy Markdown
Contributor

soronpo commented Apr 17, 2026

@soronpo @Gedochao it's a bit unfortunate that we skipped a SIP meeting or two, as this could very likely have been approved by the committee in time for 3.9.0-RC1

You mean approve the update as experimental? I don't think we need even a meeting for that since changes being made are an inherent part of the experimental phase. However, we cannot promote it to a stable feature before 3.10.

@SethTisue
Copy link
Copy Markdown
Member

SethTisue commented Apr 17, 2026

we cannot promote it to a stable feature before 3.10

right, agreed, we're just trying to see if scala/scala3#25850 can make 3.9

sorry, I should have been clearer

@SethTisue
Copy link
Copy Markdown
Member

Core agreed today that the implementation can land in 3.9, assuming no new doubts arise at the April 24 SIP meeting.

Reasoning: the feature is experimental anyway, and the implementation changes don't appear risky.

@soronpo
Copy link
Copy Markdown
Contributor

soronpo commented Apr 22, 2026

Great news @mberndt123 !

@bjornregnell
Copy link
Copy Markdown

bjornregnell commented Apr 23, 2026

@mberndt123 One minor thing: In example 1 there is reference to a "compiler bug" -- for the record and for later reading when this is merged it would be good if you could insert a link to an actual issue on the compiler so it will be easy to eventually see when it is fixed for the ones landing on the sip text in any kind of future...

Welcome to Scala 3.9.0-RC1-bin-20260423-5d6b9a4-NIGHTLY-git-5d6b9a4 (25.0.2, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.
                                                                                                                              
scala> def foo(vector: Vector[Int]) =
         vector match
           case Nil => 0
           case _ => 1
       
1 warning found
def foo(vector: Vector[Int]): Int
-- [E030] Match case Unreachable Warning: ------------------------------------------------------------------------------------
3 |    case Nil => 0
  |         ^^^
  |         Unreachable case
                                                                                                                              
scala> foo(Vector())
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by scala.runtime.LazyVals$ (file:/home/bjornr/.cache/coursier/v1/https/repo.scala-lang.org/artifactory/maven-nightlies/org/scala-lang/scala-library/3.9.0-RC1-bin-20260423-5d6b9a4-NIGHTLY/scala-library-3.9.0-RC1-bin-20260423-5d6b9a4-NIGHTLY.jar)
WARNING: Please consider reporting this to the maintainers of class scala.runtime.LazyVals$
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
val res0: Int = 0

(res0 should be 1...)

@bjornregnell
Copy link
Copy Markdown

bjornregnell commented Apr 23, 2026

...and many thanks for your thorough investigations and well-written SIP :) @mberndt123

@mberndt123
Copy link
Copy Markdown
Author

mberndt123 commented Apr 26, 2026

@bjornregnell
I haven't been able to find a bug report for this, so I've made one (scala/scala3#25933). I also added links to the implementation PRs.
Thank you for your feedback.

SolalPirelli pushed a commit to scala/scala3 that referenced this pull request Apr 28, 2026
…25850)

Hi,

after collecting feedback for strictEqualityPatternMatching
([SIP-67](scala/improvement-proposals#97)), I've
come to the conclusion that only enabling this behaviour for `case
object`s isn't useful and it's better to enable it for all `object`s.
Declaring ADTs without the `case` modifier for the relevant objects
isn't that uncommon and there's no reason to not have it work in that
case, too.
I've amended SIP-67 accordingly and I hope the SIP committee will
approve the change soon.
mbovel pushed a commit to mbovel/dotty that referenced this pull request May 4, 2026
…cala#25850)

Hi,

after collecting feedback for strictEqualityPatternMatching
([SIP-67](scala/improvement-proposals#97)), I've
come to the conclusion that only enabling this behaviour for `case
object`s isn't useful and it's better to enable it for all `object`s.
Declaring ADTs without the `case` modifier for the relevant objects
isn't that uncommon and there's no reason to not have it work in that
case, too.
I've amended SIP-67 accordingly and I hope the SIP committee will
approve the change soon.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.