Responses inline.

On Mon, Jul 27, 2026 at 5:35 AM Jochen Theodorou <[email protected]> wrote:
>
> On 7/26/26 14:03, Paul King wrote:
> [...]
> > So far so good. But there are two flavours:
> >
> > 1. New/opt-in overloads - uncontroversial, you're choosing the tagged
> > method explicitly.
> > 2. *Preemption* - this handles the case where both a tagged method and
> > non-tagged method could both match. If we automatically preferred the
> > tagged method, this would be a silent behaviour change, but it is
> > exactly what we sometimes want. So, it's currently gated behind a
> > config allowlist: `CompilerConfiguration.classTagPreemptionTargets`,
> > defaulting to just `withDefault`.
>
> if we are going by the book then essentially method selection here
> selects two equal methods and should fail compilation. So the question
> is that if you want this pass compilation, why is one method better than
> the other. Without a solid answer to this it should fail compilation.
> "it is exactly what we *sometimes* want" is not solid imho. My vote
> would be for no preemption targets and compilation failure. Do we have
> such cases in DGM?

I perhaps should have included more background in my previous email.
In GROOVY-11807, we have a case where the "plain" `withDefault`
method lets a `String` key slip into a `Map<Number,?>`, then blows up
later with a `GroovyCastException` when you read `keySet()`. It silently
corrupts the map. PR #2641 fixed this by adding *checked* `withDefault`
overloads that wrap the backing map with `asChecked(...)` so bad
keys/values fail fast at insertion. But the user has to supply the classes
that the compiler already knows. GROOVY-12115 is about a reify
mechanism so the user doesn't have to supply those classes - well
at least for static Groovy. The Java style long-hand remains for
dynamic Groovy.

So when preemption picks the tagged overload, it isn't arbitrating
between two equals - it's routing existing `withDefault { }` code onto
the **correct, fail-fast** path instead of the known-buggy lenient one,
well at least under @CompileStatic/@TypeChecked. And they aren't
two equals because they have different arity, we are treating them
as pseudo equals with reification in mind.

So preemption means existing `withDefault {}` call sites keep compiling
and silently become type-safe with zero source edits, but it's a
behavior change for anyone who *relied* on the lenient behavior.
The "withDefault" case is the only case where we need preemption
and it's the case we just added to solve GROOVY-11807 (with this
potential change in mind).

If we didn't care about breaking binary compatibility we might consider
deprecating or removing the under-constrained/lenient `withDefault`
variant but, apart from binary compatibility, that variant is actually
perfectly fine for dynamic Groovy with raw maps which could be the
case for many scripts.

> [...]
> > My gut feel is leave as is, we can always add a system property or
> > dedicated commandline option later. And I think just "withDefault" to
> > start with. Anyone designing new APIs can just factor in ClassTag from
> > the start - it will only be where existing APIs exists and folks want
> > to retrofit the "reified" shorthand after the fact where more
> > allowlist items would be needed.
>
> Well, if nothing int DGM requires a rethinking of this I would still
> vote for compilation error. A failing program that with a change then is
> compilable is not break of compatibility

The only case we have that needs fixing is "withDefault". Other cases
that are good candidates for retrofitting with @ClassTag, like "asChecked",
don't have the no-arg historical baggage. We can opt-in to adding
@ClassTag if we want to provide our users with Scala-like brevity:
`listWithGenerics.asChecked()`

As another example, consider a `flip()` that inverts a map with
unique values, returning a **runtime-checked** inverse so later
inserts stay honest:

  Map<String, Integer> ages = [Alice: 42, Bob: 39]
  // Today — you must restate both types the compiler already knows,
  // because the *returned* map needs real Class tokens to enforce
itself at runtime:
  Map<Integer, String> byAge = ages.flip(Integer, String)

The tokens aren't decoration - erasure means the returned object has no
idea it's `Map<Integer,String>` unless you hand it
`Integer.class`/`String.class`.
Without them, `flip()` can only give back a raw, unchecked map and you
lose the guarantee.

  // With @ClassTag — the compiler reads K and V from the receiver's
Map<String,Integer>
  // and injects Integer.class, String.class at the declared positions:
  Map<Integer, String> byAge = ages.flip()

> > One forward-looking note: `@ClassTag` deliberately only reifies the
> > erased `Class`, which is all today's `isInstance`-style consumers
> > need. A fuller "TypeTag" tier carrying the complete generic signature
> > (à la Scala) could be a Groovy 7 conversation, but it's out of scope
> > here.
>
> my main issue right now is that we introduce a concept from Scala, but
> only partially and it got deprecated in Scala 3 as well. I did of course
> not look at the new type system in Scala 3 all that much, so I cannot
> really tell - but I wonder if we would here introduce something just to
> deprecate it later.

Here is AI's read on the Scala situation, so I don't think it's a
problem for us:

----

It's **`ClassTag` that survived into Scala 3; `TypeTag`/`WeakTypeTag`
are the ones that got dropped.**

- `scala.reflect.ClassTag` is alive and actively used in Scala 3
(array creation, generic pattern matching). It is **not deprecated** —
the only "deprecated" smell is that it extends a
`ClassManifestDeprecatedApis` ancestor trait; `ClassTag` itself is
current.
- `TypeTag`/`WeakTypeTag` lived in `scala.reflect.runtime.universe`,
which depended on **runtime reflection** — and Scala 3 dropped runtime
reflection wholesale. So they didn't get deprecated *on their own
merits*; they fell with the subsystem they rode on. The gap is now
filled by third-party libs (izumi-reflect) and, for macros,
`scala.quoted.Type` + `TypeTest`/`Typeable`.

Two things follow for us:

1. Starting with `ClassTag` is starting with the **durable**
abstraction, not the doomed one — the opposite of
introducing-only-to-deprecate.
2. **Groovy can host both tiers without conflict**, and the Scala
worry doesn't transfer: TypeTag died in Scala 3 because Scala 3 killed
*runtime reflection* — Groovy has never lacked runtime type machinery.
A future `TypeTag` (full generic signature) would *complement*
`ClassTag` (erased `Class`, all today's `isInstance` consumers need),
not replace it. We'd only need it when a consumer must inspect nested
generics, e.g. List<List<String>>; nothing about shipping `ClassTag`
now forecloses it or forces its deprecation later.

Sources for the Scala point, if you want to cite/verify:
- [ClassTag — Scala 3
API](https://www.scala-lang.org/api/3.1.1/scala/reflect/ClassTag.html)
- [Dropped Features — Scala 3 Migration
Guide](https://docs.scala-lang.org/scala3/guides/migration/incompat-dropped-features.html)
- [TypeTags and Manifests — Scala Reflection
docs](https://docs.scala-lang.org/overviews/reflection/typetags-manifests.html)
- [izumi-reflect (Scala-3 TypeTag
replacement)](https://github.com/zio/izumi-reflect)

----

Reply via email to