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)
>
> ----

Reply via email to