Why is .runtimeChecked required when matching "rest" of sequence extractor?

Hi,

Why does this code:

object PathParts:
  def unapplySeq(s: String) =
    s.split('/').toSeq
...
val PathParts(first, _*) = "one/two/three"

generate this warning:

[warn] ./src/main/scala/chapter-11.scala:90:32
[warn] pattern's type String* does not match the right hand side expression's type String
[warn]
[warn] If the narrowing is intentional, this can be communicated by adding `.runtimeChecked` after the expression,
[warn] which may result in a MatchError at runtime.
[warn] This patch can be rewritten automatically under -rewrite -source 3.8-migration.
[warn]     val PathParts(first, _*) = "one/two/three"
[warn]

While tacking a “.runtimeChecked” after the string does prevent the warning from printed, this does not seem to be documented anywhere:

The Scala 3 online book: Control Structures | Scala 3 — Book | Scala Documentation

The Scala 3 reference: Vararg Splices

The Tour of Scala: Extractor Objects | Tour of Scala | Scala Documentation

It or its precursor, a required “@unchecked” annotation, seems to have appeared between between Scala 3.2 and 3.3 judging by “-rewrite -source 3.x-migration” experiments. It is not mentioned in any release notes.

TiA, Mike

.runtimeChecked is fairly new, so I guess they haven’t got around to documenting it yet. Maybe someone can make a PR to one of those pages? (I’m not sure what to write there.)

runtimeChecked is just the old @unchecked annotation. I believe the warning here comes from the fact that the compiler thinks that the result of the PathParts unapply might be empty, so concludes that the pattern match might fail. That’s why the runtimeChecked is needed.

Hmm. If the unapply returns an empty Seq, I would have expected first to be None. Also the message “type String does not match the right hand side expression’s type String” seems wrong, surely a single String matches zero-or-more String (spread). The type for String* is Seq[String] which can hold zero, or more String after all. And warning even happens when only the whole spread / sequence is matched.

Metals reports (so presumably the compiler infers) the first’s type as String, but shouldn’t it be Option[String]?

When tacking on the .runtimeChecked, no error is generated at runtime; first is (surprisingly!) set to the empty string whether unapply returns a bare Seq or wraps it in an Option. The four combinations of wrapping or not and matching the first+rest vs the whole sequence iare n the program below. All generate the compiler warning.

The output:

Compiled project (Scala 3.8.3, JVM (25))
With path = <one/two/three>
Option first= rest=ArraySeq(two, three)
Option first is empty: false option: false, String=true
Bare first= rest=ArraySeq(two, three)
Bare first is empty: false option: false, String=true
With path = <>
Option first=<> rest=ArraySeq()
Option first is empty: true option: false, String=true
Bare first=<> rest=ArraySeq()
Bare first is empty: true option: false, String=true

And the program:

object PathPartsOption:
  def unapplySeq(s: String) = Some(s.split('/').toSeq)

object PathPartsBare:
  def unapplySeq(s: String) = s.split('/').toSeq

@main
def demoPathParts() =
  for path <- Array("one/two/three", "") do
    println(s"With path = <${path}>")

    val PathPartsOption(allOpt*) = path.runtimeChecked
    val PathPartsOption(firstOpt, restOpt*) = path.runtimeChecked
    println(s"  Option first=<${firstOpt}> rest=${restOpt}")
    println(s"  Option first is empty: ${firstOpt == """"""} option: ${firstOpt.isInstanceOf[Option[String]]}, String=${firstOpt.isInstanceOf[String]}")

    val PathPartsBare(allBare*) = path.runtimeChecked
    val PathPartsBare(firstBare, restBare*) = path.runtimeChecked
    println(s"  Bare first=<${firstBare}> rest=${restBare}")
    println(s"  Bare first is empty: ${firstBare == """"""} option: ${firstBare.isInstanceOf[Option[String]]}, String=${firstBare.isInstanceOf[String]}")

Thank you for taking a look.

The only doc I consult (when necessary) is the spec.

I agree that it is confusing. I find the previous innovations in Scala 2, and the further changes in Scala 3, interesting and exciting yet also intuitive – until I consult the reference.

I should emphasize that the methods on Java’s String are endlessly confusing to me. I consult the Javadoc whenever I am required to use the (terrible) API. Also, Spellcheck wants to amend Javadoc to avocado. That is the only time I will love spellcheck. Is there already a project named Javocado?

It’s late on a weekend, so I apologize in advance for a desultory answer, and also for using the word “desultory”.

I think I encounter this question mostly when using regex extractors. The regex is confusing to begin with, and how the unapplySeq interacts with pattern matching is additionally confusing.

This is clearly not the case. The unapplySeq ([sic]!) returns a seq of an empty string (see my comment about the API). The pattern matches, and the first is the empty string.

Sorry, I just ran out of steam. There are attempts to make regex pattern matches more amenable to pattern matches, using macros. For example, so that a failed match is None instead of null.

I’m not sure which version you are using and which messages you expect.

➜  snips cat unapply-seq.scala
object PathPartsOption:
  def unapplySeq(s: String) = Some(s.split('/').toSeq)

object PathPartsBare:
  def unapplySeq(s: String) = s.split('/').toSeq

@main
def demoPathParts() =
  for path <- Array("one/two/three", "") do
    println(s"With path = <${path}>")

    val PathPartsOption(allOpt*) = path.runtimeChecked
    val PathPartsOption(firstOpt, restOpt*) = path.runtimeChecked
    println(s"  Option first=<${firstOpt}> rest=${restOpt}")
    println(s"  Option first is empty: ${firstOpt.isEmpty} option: ${firstOpt.isInstanceOf[Option[String]]}, String=${firstOpt.isInstanceOf[String]}")
    println(s"  Option all=<${allOpt}>")

    val PathPartsBare(allBare*) = path.runtimeChecked
    val PathPartsBare(firstBare, restBare*) = path.runtimeChecked
    println(s"  Bare first=<${firstBare}> rest=${restBare}")
    println(s"  Bare first is empty: ${firstBare.isEmpty} option: ${firstBare.isInstanceOf[Option[String]]}, String=${firstBare.isInstanceOf[String]}")
    println(s"  Bare all=<${allBare}>")
    println(s"  Bare result=<${PathPartsBare.unapplySeq(path)}>")
➜  snips scala-cli run --server=false -S 3.8.4-RC2 unapply-seq.scala
-- Warning: /home/amarki/snips/unapply-seq.scala:15:69 -------------------------
15 |    println(s"  Option first is empty: ${firstOpt.isEmpty} option: ${firstOpt.isInstanceOf[Option[String]]}, String=${firstOpt.isInstanceOf[String]}")
   |                                                                     ^^^^^^^^
   |this will always yield false since type String is not a subclass of class Option
-- Warning: /home/amarki/snips/unapply-seq.scala:21:68 -------------------------
21 |    println(s"  Bare first is empty: ${firstBare.isEmpty} option: ${firstBare.isInstanceOf[Option[String]]}, String=${firstBare.isInstanceOf[String]}")
   |                                                                    ^^^^^^^^^
   |this will always yield false since type String is not a subclass of class Option
2 warnings found
With path = <one/two/three>
  Option first=<one> rest=ArraySeq(two, three)
  Option first is empty: false option: false, String=true
  Option all=<ArraySeq(one, two, three)>
  Bare first=<one> rest=ArraySeq(two, three)
  Bare first is empty: false option: false, String=true
  Bare all=<ArraySeq(one, two, three)>
  Bare result=<ArraySeq(one, two, three)>
With path = <>
  Option first=<> rest=ArraySeq()
  Option first is empty: true option: false, String=true
  Option all=<ArraySeq()>
  Bare first=<> rest=ArraySeq()
  Bare first is empty: true option: false, String=true
  Bare all=<ArraySeq()>
  Bare result=<ArraySeq()>

An extractor returning an empty sequence

object Empty:
  def unapplySeq(s: String) = Nil

will compile but throw

val Empty(x, xs*) = ""

Edit: thanks for the opportunity to use sic and seq together in a sentence.