GEP-17
Abstract
This GEP defines a consistent mechanism for designating class
members as internal — meaning they should be excluded from
property introspection, serialization, and AST-transform-generated
code such as @ToString, @EqualsAndHashCode, and @TupleConstructor.
The mechanism uses the existing @groovy.transform.Internal annotation
(introduced in Groovy 2.5.3) and extends its scope from a compile-time
hint to a first-class runtime-honoured marker.
Motivation
Groovy AST transforms frequently generate fields with $ in the name
(e.g. $hash$code from @EqualsAndHashCode, $fieldName from @Lazy,
$reentrantlock from @ReadWriteLock). These fields are implementation
details that should not appear in toString() output, equality comparisons,
JSON serialization, or property listings.
Historically, filtering was done inconsistently:
-
AST transforms used a name-based convention:
deemedInternalName(name)checkedname.contains("$"). Each consuming transform applied this check independently viashouldSkip/shouldSkipUndefinedAware. -
Runtime (
MetaClassImpl.getProperties()) had no filtering for internal properties at all. This meantJsonOutput.toJson()andprintlncould expose$-containing fields. -
BeanUtils.getAllProperties()(compile-time) checked@Internalon getter methods, butMetaClassImpldid not. -
User-defined internal properties without
$in the name had no way to opt in to the filtering.
This inconsistency led to bugs where internal fields leaked into
serialized output, and AST transform authors had to independently
remember to add $ filtering.
Design
The @Internal annotation
The existing annotation serves as the single source of truth:
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Internal { }
Compile-time: AST transforms
Producers — transforms that generate internal fields
Transforms that create fields intended to be hidden from users
should annotate them with @Internal:
FieldNode hashField = cNode.addField("$hash$code", ACC_PRIVATE | ACC_SYNTHETIC, ...);
markAsInternal(hashField); // from AnnotatedNodeUtils
The utility methods are in org.apache.groovy.ast.tools.AnnotatedNodeUtils:
-
markAsInternal(T node)— adds@Internalannotation (idempotent) -
isInternal(AnnotatedNode node)— checks for@Internalannotation -
deemedInternal(AnnotatedNode node)— checks both@Internalannotation and$name convention (backward compatible)
Consumers — transforms that iterate properties
Transforms that iterate over properties or fields to generate code
(e.g. @ToString, @EqualsAndHashCode) should use the node-aware
skip methods from AbstractASTTransformation:
// Old way (name-based only):
if (shouldSkipUndefinedAware(pNode.getName(), excludes, includes, allNames)) continue;
// New way (checks @Internal annotation + $ convention):
if (shouldSkipUndefinedAware(pNode, excludes, includes, allNames)) continue;
The allNames parameter continues to mean "include internal properties",
matching the behaviour of @ToString(allNames=true).
Runtime: MetaClassImpl.getProperties()
MetaClassImpl.getProperties() now checks for @Internal on:
-
CachedFieldentries: checksfield.isAnnotationPresent(Internal.class) -
MetaBeanPropertyentries: checks the backing field, getter, and setter for@Internal
This means @Internal-annotated properties are automatically excluded from:
-
JsonOutput.toJson() -
FormatHelper.format()(used byprintln, string interpolation) -
obj.properties(the Groovy properties map) -
Any code that iterates
metaClass.properties
Direct property access (obj.secret = x, obj.secret) still works.
User-facing usage
Users can annotate their own fields to exclude them from introspection and serialization:
import groovy.transform.Internal
import groovy.transform.ToString
@ToString
class Account {
String name
@Internal String internalTag
}
def a = new Account(name: 'test', internalTag: 'x')
assert a.toString() == 'Account(test)' // internalTag excluded
assert a.internalTag == 'x' // direct access still works
Backward compatibility
The $ name convention is preserved. deemedInternal(node) checks
both @Internal and name.contains("$"), so existing code that
relies on $-named fields being filtered continues to work.
The deemedInternalName(String) method remains available for callers
that only have a name string (e.g. java.beans.PropertyDescriptor).
Current status
Infrastructure (done — PR#2467)
| Component | Status |
|---|---|
|
Done |
Node-aware |
Done — in |
|
Done |
Consumers — transforms that iterate properties (done — PR#2467)
All use node-aware skip methods that check both @Internal and $ convention:
@ToString, @EqualsAndHashCode, @TupleConstructor, @MapConstructor,
@Builder, @Delegate, @Immutable.
Producers — transforms that create internal fields
| Component | Status |
|---|---|
|
Done (PR#2467) |
|
Done (PR#2467) |
|
Done (PR#2467) |
|
Done |
|
Done |
|
Done — these fields (e.g. |
Cleanup
Removed markAsInternal and deemedInternal wrapper methods from
AbstractASTTransformation. Call sites now import directly from
AnnotatedNodeUtils, avoiding method shadowing issues.
Deferred — compiler internals
| Component | Notes |
|---|---|
|
Adding |
|
Adding |
Both continue to rely on the $ naming convention via
deemedInternal() / deemedInternalName().
Path to deprecating the $ fallback
The $ name convention (deemedInternalName) cannot be deprecated
immediately, even once all Groovy’s own producers use @Internal.
Third-party frameworks and AST transforms that generate $-named
fields also rely on this convention. The deprecation path is:
-
Document the
@Internalannotation as the recommended approach for framework authors (this GEP serves as that design note). -
Allow time for framework authors to adopt
@Internalin their own transforms and generated code. -
Deprecate
deemedInternalName()in a future version once adoption is sufficient. -
Remove the
$fallback eventually, potentially retaining a more surgical check for specific known compiler fields (e.g.__$stMC) if they cannot be annotated.
References
-
GROOVY-11928 — MetaClassImpl.getProperties() should respect @Internal annotation
-
GROOVY-11516 — Improve consistency of treatment for internal properties (superseded)
-
PR #2118 — Original draft PR (name-based
$filtering, closed in favour of annotation approach)