GEP-17


Metadata
Number

GEP-17

Title

Design Note: Consistent handling of internal properties via @Internal

Version

1

Type

Feature

Status

Draft

Leader

Paul King

Created

2026-04-13

Last modification 

2026-04-14

JIRA

GROOVY-11928

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) checked name.contains("$"). Each consuming transform applied this check independently via shouldSkip/shouldSkipUndefinedAware.

  • Runtime (MetaClassImpl.getProperties()) had no filtering for internal properties at all. This meant JsonOutput.toJson() and println could expose $-containing fields.

  • BeanUtils.getAllProperties() (compile-time) checked @Internal on getter methods, but MetaClassImpl did 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 @Internal annotation (idempotent)

  • isInternal(AnnotatedNode node) — checks for @Internal annotation

  • deemedInternal(AnnotatedNode node) — checks both @Internal annotation 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:

  • CachedField entries: checks field.isAnnotationPresent(Internal.class)

  • MetaBeanProperty entries: checks the backing field, getter, and setter for @Internal

This means @Internal-annotated properties are automatically excluded from:

  • JsonOutput.toJson()

  • FormatHelper.format() (used by println, 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

AnnotatedNodeUtils.markAsInternal/isInternal/deemedInternal

Done

Node-aware shouldSkip/shouldSkipUndefinedAware overloads

Done — in AbstractASTTransformation

MetaClassImpl.getProperties() — respects @Internal

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

@EqualsAndHashCode — marks $hash$code

Done (PR#2467)

@Lazy — marks backing field

Done (PR#2467)

@ReadWriteLock — marks lock fields

Done (PR#2467)

@ToString — marks $to$string cache field

Done

@Synchronized — marks $LOCK and $lock

Done

TraitComposer — marks trait implementation fields

Done — these fields (e.g. com_example_Named__name) do not contain $, so the name convention never caught them. This was the only case where internal fields were leaking into metaClass.properties.

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

Verifier (__$stMC)

Adding @Internal causes NPE in ExtendedVerifier.visitAnnotations during nested compilation contexts (triggered by evaluateExpression in static type checking) where this.source is null. Needs a null guard in ExtendedVerifier or an alternative mechanism.

InnerClassVisitor (this$0)

Adding @Internal causes duplicate annotation errors in some inner class scenarios. Needs investigation of the annotation duplication path.

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:

  1. Document the @Internal annotation as the recommended approach for framework authors (this GEP serves as that design note).

  2. Allow time for framework authors to adopt @Internal in their own transforms and generated code.

  3. Deprecate deemedInternalName() in a future version once adoption is sufficient.

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