Let me sharpen one point (excuse the semi-AI wording), because there are two categories and `@ClassTag` only belongs to one:
Category 1 - ordinary generic methods (e.g. BiMap `flip`): `static <K,V> Map<V,K> flip(Map<K,V> self)` needs *no* Class token. It's fully type-safe at compile time through plain generics, the no-arg signature is the natural one, and it loses safety only under raw types / dynamic dispatch - **exactly as the Java equivalent would.** `@ClassTag` has nothing to say here. It isn't trying to enable, replace, or police these methods, and it can't rescue the raw-type holes (there's no static type info to synthesize from - same hole Java has). Category 2 - the runtime-enforcement family (`asChecked` ≈ `Collections.checkedList`/`checkedMap`, or the previous email's *runtime-checked* Map example): These *inherently* need a runtime `Class` token, because their entire job is to re-establish at runtime the discipline erasure threw away. That token isn't genericity - it's the mechanism. Java makes you write it out (`Collections.checkedList(xs, String.class)`); that's the "API noise." `@ClassTag` targets **only category 2**, and only under static Groovy: it synthesizes the token the compiler already knows, so the noise disappears. Dynamic Groovy keeps the explicit token - the Java long-hand - because there's nothing to synthesize from. So the honest one-liner: **`@ClassTag` isn't a genericity feature, it's a runtime-type-enforcement ergonomics feature.** The token is required precisely (and only) where you're deliberately *buying back* the runtime safety erasure removed - and static Groovy is exactly the context where it can then be hidden. On Mon, Jul 27, 2026 at 8:31 AM Paul King <[email protected]> wrote: > > 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) > > ----
