Skip to content

Add support for java records in pattern matching - #26497

Open
Florian3k wants to merge 15 commits into
scala:mainfrom
Florian3k:java-record-patmat2
Open

Add support for java records in pattern matching#26497
Florian3k wants to merge 15 commits into
scala:mainfrom
Florian3k:java-record-patmat2

Conversation

@Florian3k

Copy link
Copy Markdown
Contributor

Fixes #20561
Supersedes #24140

Implements pattern matching support for Java records.

  • Record's fields are saved in ctx.base.javaRecordsFields (and temporarily as Attachment in JavaParsers, since symbols do not exists yet)
  • Record class Rec used as an extractor pattern generates anonymous class with identity unapply method, which is then used in pattern matching. Existing static unapply in class takes precedence.
  • Record class returned from unapply is treated similar to products, with all record's components being used in matching.

Have you relied on LLM-based tools in this contribution?

Yes, for codebase exploration.

How was the solution tested?

New automated tests

@SethTisue

SethTisue commented Jul 8, 2026

Copy link
Copy Markdown
Member

On the previous PR, Martin wrote:

I think whatever we do, this needs a spec change.

We should avoid a situation where we add something to the implementation and leave the spec change for later. So,
ideally, this PR is accompanied by a PR against the Scala spec. And the changes are either summarized or linked to here.

copying it here so it doesn't get forgotten.

afaics, wouldn't even need to be a separate PR, could be in the same PR, since the spec is here in-repo

@He-Pin

He-Pin commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Seems we can't have this in 3.9.x?

@Gedochao

Gedochao commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Seems we can't have this in 3.9.x?

We indeed can't have this in 3.9.x

@scala scala deleted a comment from He-Pin Jul 11, 2026
@tanishiking

tanishiking commented Jul 11, 2026

Copy link
Copy Markdown
Member

I have a question, is there any performance difference vs the Java native one ...

@He-Pin I took the liberty of deleting your comment, so that no one can see inside the OpenJDK source code. Please do not paste a link to the OpenJDK source code, see:

https://github.com/scala/scala3/blob/main/CONTRIBUTING.md#forbidden

@He-Pin

He-Pin commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Sorry, can we make use of https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/runtime/SwitchBootstraps.html on higher version of Java , which can generate smaller bytecode.

@Florian3k
Florian3k force-pushed the java-record-patmat2 branch from 4af5785 to 8821342 Compare August 12, 2026 13:50
@Gedochao
Gedochao requested a review from lrytz August 12, 2026 14:08
@Gedochao Gedochao added needs-minor-release This PR cannot be merged until the next minor release and removed stat:do not merge stat:needs spec labels Aug 12, 2026

@lrytz lrytz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks very good, thank you!

I have one question / discussion point about java records with a varargs parameters (see separate comment).

Claude found one bug: in pipelined compilation, there is no way to recover the record field names. The javaRecordsFields map is populated for parsed classfiles and Java source files. But in pipelined compilation, the record declaration comes from the tasty unpickler (the corresponding tasty was produced from the Java record AST in the previous compilation).

When unpickling the record class tasty, it seems we don't have a way to identify the primary constructor (there can be multiple constructors, it's not necessarily the lexically first), so we don't know what the field names are, and we cannot populate the javaRecordsFields map.

One solution around this is to store the field names in an internal annotation attached to the Java record class. This annotation is pickled to tasty, so it's still there in pipelined compilation. As a result, the global javaRecordsFields map would no longer be needed, the names can be read from the annotation.

That proposal is implemented on my branch.

Another observation: the synthetic class with the unapply method was only elided in the monomorphic case. Fix also in my branch.

Finally, one case that this PR implements is missing from the spec, the

object Baz:
  def unapply(b: Baz): JavaRecord = ...

It's a bit awkward to add it to the spec because option-less pattern matching is also not fully in the spec... Proposal also in my branch.

Branch is here, let me know what you think: https://github.com/lrytz/scala3/commits/pr26497/

Comment thread docs/_spec/08-pattern-matching.md
@Gedochao Gedochao added this to the 3.10.0 milestone Aug 18, 2026
@Florian3k

Copy link
Copy Markdown
Contributor Author

@lrytz Good catches. I've looked at your branch. I'd pull all of it, but I'm a little wary of JavaRecordFields annotation. This is something I've tried before in #24140 and there was some pushback against it. I think we need to revisit this, get some more opinions.

@lrytz

lrytz commented Aug 18, 2026

Copy link
Copy Markdown
Member

Yeah, the internal annotation is a bit unfortunate.

If we could figure out in Applications.scala / def javaRecordFields which is the primary constructor of a record class, we would not need to store the names anywhere. We'd have to identify the primary constructor in the classfile parser and in the java source parser, and make sure the marker we attach to the primary constructor is something that is also visible after Tasty unpickling. That's probably possible, I think Claude initially did that using the Synthetic flag, but I found that a bit too arbitrary. It could be an internal annotation, but then we can just as well put the record names into an annotation 🤷‍♂️

The problem with the global Map in Context is that it doesn't survive across Tasty.

@Florian3k

Copy link
Copy Markdown
Contributor Author

Yeah, I'd go with this annotation if there aren't strong objections from the compiler team.

@Florian3k Florian3k assigned Florian3k and unassigned lrytz Aug 19, 2026
@SethTisue

Copy link
Copy Markdown
Member

which is the primary constructor of a record class

I asked at core in what sense a Java record can even be said to have a "primary" constructor; primary constructors are a Scala-specific concept. For "the record" (ha ha ha!), I see now at https://docs.oracle.com/en/java/javase/22/language/records.html that Java records do have what the Java people call a "canonical" constructor:

A record class declares the following members automatically:
...

  • A canonical constructor whose signature is the same as the header. This constructor assigns each argument from the new expression that instantiates the record class to the corresponding component field.
    ...
    You can define alternative, noncanonical constructors whose argument list doesn't match the record's type parameters. However, these constructors must invoke the record's canonical constructor

There is also new syntax for a "compact constructor", which makes constructors easier to write.

Florian3k and others added 2 commits August 23, 2026 17:07
Replace the ctx.base.javaRecordsFields map and the parser-to-typer
attachment with an internal annotation
scala.annotation.internal.JavaRecordFields(names*) attached to the
record class, following the WitnessNames precedent:
 - JavaParsers attaches it from the record header,
 - the ClassfileParser attaches it from the Record classfile attribute,
 - annotations are pickled, so the names are also available on record
   symbols unpickled from TASTy, which neither the map nor any other
   non-pickled state can provide.

The latter fixes a compiler crash in pipelined compilation, where the
Java signatures of an upstream project are consumed from TASTy before
its class files exist: record symbols unpickled there went through
neither of the two paths that populated the map, and matching on such
a record failed an assertion. Tested in the new
sbt-test/pipelining/Xjava-tasty-record-match scripted test.

Cost: one new class in scala.annotation.internal, with a MiMa filter
entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5aa1a06)
Florian3k and others added 5 commits August 24, 2026 22:16
InlinePatterns eliminates the anonymous class and identity unapply call
generated for record patterns, but its App extractor did not look
through TypeApply, so patterns on polymorphic records kept an anonymous
class per pattern and an allocation plus a virtual call per match
execution. BetaReduce.reduceApplication already handles type arguments,
so it suffices to unwrap TypeApply.

Add a bytecode test showing that record patterns compile to plain
accessor calls, with no anonymous classes and no unapply call, for
monomorphic and polymorphic records alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5fcda16)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b92deff)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 82deff9)
@Florian3k

Copy link
Copy Markdown
Contributor Author

cc @lrytz
Changes:

  • Changed the unapply to return tuple of components, removed the support for returning a record from unapply. Simplifies the implementation a bit I think, and reduces changes to the spec.
  • Switched to annotations (cherry-picked your commit)
  • Generate unapplySeq for vararg records
  • Beta-reduce polymorphic unapply (cherry-picked your commit)
  • Added test for matching null ((cherry-picked your commit)

@Florian3k
Florian3k requested a review from lrytz August 25, 2026 14:51
@Florian3k Florian3k assigned lrytz and unassigned Florian3k Aug 25, 2026

@lrytz lrytz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the unapply to return tuple of components, removed the support for returning a record from unapply. Simplifies the implementation a bit I think, and reduces changes to the spec.

I think that's the right call.

Now we can consider to also simplify the spec: instead of defining record patterns in the spec, explain the shape of the synthetic unapply / unapplySeq methods that a Scala compiler adds to Java record classes. @sjrd wdyt?

Comment thread compiler/src/dotty/tools/dotc/core/classfile/ClassfileParser.scala Outdated
Comment thread docs/_spec/08-pattern-matching.md
else if isVararg then
val lastField = accessor(fields.last)
val defn.ArrayOf(lastElemType) = lastField.tpe.runtimeChecked
val lastFieldSeq = wrapArray(lastField, lastElemType)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wraps the array in an immutable Seq, which can be observed.

scala> val a = A("x", "y", "z")
val a: A = A[x=x, xs=[Ljava.lang.String;@2ca50ae3]

scala> val xs = a.xs
val xs: Array[String] = Array("y", "z")

scala> val sq = a match { case A(_, sq*) => sq }
val sq: Seq[String] = ArraySeq("y", "z")

scala> xs(0) = "buh"

scala> sq
val res0: Seq[String] = ArraySeq("buh", "z")

I'm not sure if we should keep it this way, or always clone the array. Should I ask in core?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it'd be consistent with pattern matching on arrays.

scala> val xs = Array("x", "y", "z")
val xs: Array[String] = Array(x, y, z)
                                                                                                                    
scala> val ys = xs match { case Array(r*) => r }
val ys: Seq[String] = ArraySeq(x, y, z)
                                                                                                                    
scala> val zs = xs match { case Array(_, r*) => r }
val zs: Seq[String] = ArraySeq(y, z)
                                                                                                                    
scala> xs(1) = "buh"
                                                                                                                    
scala> ys
val res0: Seq[String] = ArraySeq(x, y, z)
                                                                                                                    
scala> zs
val res1: Seq[String] = ArraySeq(y, z)

But I think you can ask

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, that's implemented in UnapplySeqWrapper. So let's clone as well.

Perhaps we can have a similar wrapper? It would have to accept the initial non-varargs params as well. Or we keep the tuple and use array.toSeq.

Comment thread tests/run/i20561_separ/Test_2.scala
@Florian3k

Copy link
Copy Markdown
Contributor Author

Now we can consider to also simplify the spec: instead of defining record patterns in the spec, explain the shape of the synthetic unapply / unapplySeq methods that a Scala compiler adds to Java record classes.

I'm not so sure about this. Those methods are not visible to the programmer, they are synthesized on the go if we pattern match on a record. The fact that some methods are generated is more of a implementation detail imo.

@lrytz

lrytz commented Aug 26, 2026

Copy link
Copy Markdown
Member

We don't need to make unapply/unapplySeq visible to users, but I think we could simplify the spec. Even if we leave "record pattern" as a concept in the spec, it could say "a record pattern behaves the same as an extractor pattern, given a synthetic definition of unapply or unapplySeq in the record class", something like that.

@sjrd

sjrd commented Aug 26, 2026

Copy link
Copy Markdown
Member

We don't need to make unapply/unapplySeq visible to users, but I think we could simplify the spec. Even if we leave "record pattern" as a concept in the spec, it could say "a record pattern behaves the same as an extractor pattern, given a synthetic definition of unapply or unapplySeq in the record class", something like that.

That seems plausible, yes.

@Florian3k

Copy link
Copy Markdown
Contributor Author

I think we would need then to explain all the possible shapes of unapply/unapplySeq (Boolean for empty record, Tuple for regular, Tuple with Seq for vararg, only Seq for vararg only), which doesn't really feel simpler to me.

Maybe we could say instead, that record pattern for record R[...](T1, ...Tn) behaves the same as constructor pattern for case class R[...](T1, ... Tn), including vararg parameter?

Anyway, in the end I'm fine with either option.

lrytz and others added 3 commits August 26, 2026 16:09
The vararg bit of a Java record is the ACC_VARARGS flag on its canonical
constructor, so the classfile parser has to pick that constructor out among
all of the record's constructors.

Instead of matching the constructor parameter types against the types of the
component accessors, compare JVM descriptors: the descriptor of the canonical
constructor is the concatenation of the component descriptors (JLS 8.10.4),
which the `Record` attribute lists next to the component names, and no other
constructor can have the same descriptor. Record classes are always static
(JLS 8.10), so there is no leading outer parameter to account for.

This drops the type-level matching, and with it the dependency on how the
parser models ACC_VARARGS and on anything else that rewrites member infos
before the comparison (explicit nulls, constructor parameter normalization).
It also means the deferred annotation no longer forces the info of the class
it is attached to.

The new test covers a record whose *non-canonical* constructor is the vararg
one, which must not make the record itself vararg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 96986c3)
@Florian3k
Florian3k requested a review from lrytz August 28, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-minor-release This PR cannot be merged until the next minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scala Pattern Matching on Java Records (JEP-440)

8 participants