Skip to content

Enum widening sabotages match types #16299

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
b-studios opened this issue Nov 8, 2022 · 15 comments
Open

Enum widening sabotages match types #16299

b-studios opened this issue Nov 8, 2022 · 15 comments

Comments

@b-studios
Copy link
Contributor

It looks like widening of enums gets into the way when trying to use match types on them. I guess this is expected behavior, but it is still really sad.

Compiler version

Scala 3.2.0

Minimized example

enum Target {
  case Foo(n: Int)
  case Bar(s: String)
}

enum Source {
  case Foo(n: Int)
  case Bar(s: String)
}

type resolved[T] = T match {
  case Source.Foo => Target.Foo
  case Source.Bar => Target.Bar
}

def resolve[S <: Source](src: S): resolved[S] = ???

Now, for

val foo: Target.Foo = resolve(Source.Foo(42))

the match type gets stuck since Foo will be widened to Source.

Manually annotating works

val foo: Target.Foo = resolve(Source.Foo(42) : Source.Foo)

but is infeasible in practice.

Discussion

Context: I want to use enums to model ADTs in a compiler. Different phases have different trees. Match types could really nicely tell which tree types in the source are translated to which in the next phase. Manually "downcasting" by ascribing the call does not work, since I literally would have to annotate over 1000 of such calls.

In general, I am not a big fan of the widening behavior. I do understand the design considerations, but overall would be happier without it.

(Please feel free to immediately close and mark as won't fix :) )

@b-studios b-studios added the stat:needs triage Every issue needs to have an "area" and "itype" label label Nov 8, 2022
@b-studios
Copy link
Contributor Author

BTW. Enum widening also bit a few students of mine. They really didn't understand why

val f = Source.Foo(4)
f.n

doesn't type check. It is "obvious" from looking at it, that it does not cause a run time error, so why does the compiler reject it? :)

@odersky
Copy link
Contributor

odersky commented Nov 8, 2022

There were a lot of complaints before that widening for case classes did not happen. I think it was mostly from people used to ADTs that got bitten by the type inference to a subtype (which is of course not available for a classical ADT).

So we made enums more like classical ADTs as a compromise.

Note that this could be potentially solved by adding a way to specify "precise" type variables. Then the compiler would have enough information to not widen the type of Source.Foo(42).

@odersky odersky added itype:enhancement itype:language enhancement and removed stat:needs triage Every issue needs to have an "area" and "itype" label labels Nov 8, 2022
@b-studios
Copy link
Contributor Author

Thanks @odersky for your quick response.

There were a lot of complaints before that widening for case classes did not happen.

That is interesting to hear. I would have assumed that subtyping will just do its magic and in most cases it will go unnoticed.

"precise" type variables

This is a new feature you are proposing, right? Or do you say that the Singleton bound can be used for something like this?

@odersky
Copy link
Contributor

odersky commented Nov 8, 2022

No, Singleton alone is not what we want since Source.Foo(4) is not a path. There would have to be something else. We have been discussing this recently quite a bit /cc @soronpo @mbovel @smarter.

@bishabosha
Copy link
Member

bishabosha commented Nov 8, 2022

workaround:

def resolve[S <: Source.Foo | Source.Bar](src: S): resolved[S] = ???
val foo: Target.Foo = resolve(Source.Foo(42)) // works

I suggested at some point to add a compiletime type that can "split" a sealed type.

@b-studios
Copy link
Contributor Author

Thanks Jamie, that's helpful!

Any idea, why defining resolve doesn't still work? (https://scastie.scala-lang.org/RiFqXXupQ927a4zpNPhcjg)

It is not really important, though, since I will use casts internally, anyway :)

@dwijnand
Copy link
Member

dwijnand commented Nov 8, 2022

Can't tell why that doesn't work. That's exactly what match type return types are built for, so it's extra surprising.

@mbovel
Copy link
Member

mbovel commented Nov 8, 2022

Currently, only match expressions with patterns of the form t: T can be typed as match types.

You could rewrite your example as:

enum Target {
  case Foo(n: Int)
  case Bar(s: String)
}

enum Source {
  case Foo(n: Int)
  case Bar(s: String)
}

type resolved[T] = T match {
  case Source.Foo => Target.Foo
  case Source.Bar => Target.Bar
}

object Source {
  type Cases = Source.Foo | Source.Bar
}

def resolve[S <: Source.Cases](src: S): resolved[S] = src match {
  case src: Source.Foo => Target.Foo(src.n)
  case src: Source.Bar => Target.Bar(src.s)
}

val foo: Target.Foo = resolve(Source.Foo(42))

@b-studios
Copy link
Contributor Author

Sorry to be so annoying about the widening. But is there any way around it? My code base starts to look a lot like this:

image

where I just have to eta expand the constructor to prevent the widening. Of course I could define new constructors, but that would be ~150 of them.

@soronpo
Copy link
Contributor

soronpo commented Nov 8, 2022

FWIW, I checked the original example with the current @precise annotation implementation (see #15765), and it falls short of delivering. It seems match types are yet another language interaction that causes widening and needs to be tailored as well.

import annotation.precise
enum Target {
  case Foo(n: Int)
  case Bar(s: String)
}

enum Source {
  case Foo(n: Int)
  case Bar(s: String)
}

type resolved[@precise T] = T match {
  case Source.Foo => Target.Foo
  case Source.Bar => Target.Bar
}

def resolve[@precise S <: Source](src: S): resolved[S] = ???

val foo: Target.Foo = resolve(Source.Foo(42)) //error

@soronpo
Copy link
Contributor

soronpo commented Nov 8, 2022

Yet, considering that match types are a new thing, this really could be considered a bug and fixed without the need for any precise modifier on the type. Intuitively, the match type is supposed to work here, and a code that currently assumes otherwise (if such code exists) should be considered invalid.

@bishabosha
Copy link
Member

bishabosha commented Nov 9, 2022

image

the expected type (here from the P[Foo] arg) is meant to prevent widening, so i'm surprised you need type ascription here

@b-studios
Copy link
Contributor Author

is meant to prevent widening, so i'm surprised you need type ascription here

That's what I would've expected, as well. However, dropping the ascription results in the widening error:

[error] 346 |    idDef ~ valueTypeAnnotation ^^ { case id ~ tpe => ValueParam(id, Some(tpe)) }
[error]     |    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[error]     |  Found:    EffektParsers.this.Parser[effekt.source.Param]
[error]     |  Required: EffektParsers.this.PackratParser[effekt.source.ValueParam]

@dwijnand
Copy link
Member

Sorry to be so annoying about the widening. But is there any way around it?

At the risk of stating the obvious, but switching from enums to sealed traits and case classes would avoids all this, right?

@b-studios
Copy link
Contributor Author

@dwijnand Yes :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants