Repository: google/gson (Java)
Date: 2023-09-23 20:14:37+00:00
Task type: Feature

Issue:
Allow serialization of anonymous classes
**Describe the feature**
Gson should allow *serialization* of anonymous classes, the reason is that the user shouldn't care about the implementation of the code that generates the objects they are using.

For example, this code that only uses Guava and Gson looks fine and users may expect it to print `["a", "b"]`:
```
        System.out.println(gson.toJsonTree(Sets.union(
                ImmutableSet.of("a"),
                ImmutableSet.of("b"))));
```
But actually, that code prints `null`, totally unexpected to a user that is not familiar with the implementation of `Sets.union` (that function returns an instance of an anonymous class).


**Additional context**

I think this feature is well deserved because of the amount of confusion that has been around the lack of it. If we do a Google search we find several people who were caught by this issue:
* https://github.com/google/gson/issues/298
* https://github.com/google/gson/issues/762
* https://github.com/tipsy/javalin/issues/288
* https://stackoverflow.com/questions/10746278/serializing-anonymous-classes-with-gson
* https://stackoverflow.com/questions/26791752/convert-anonymous-java-object-types-to-json-using-gson
* https://stackoverflow.com/questions/55622921/custom-gson-serializer-for-anonymous-classes

And the list goes on and on.

I think what aggravates the lack of this feature it he way Gson silently serializes those instances to `null`, which is a source of silent bugs.

**NOTE:**
*Deserialization* of anonymous inner classes is problematic, I'm not asking for that to be supported. This feature request deals only with serialization.

**Possible workarounds**

I've seen suggested workarounds like:
```
gson.toJsonTree(union, TypeToken<Set<String>>(){}.getType());.
```
But notice that only works in the most simple of cases, but it doesn't work in cases where we have a Map with values of different anonymous classes:
```
ImmutableMap.of(
    "key1", Sets.union(...),
    "key2", new HashSet(){},
    "key3", new MyRecord(){}
);
```
As there is not a single TokenType I can accommodate for that disparity of values and that will behave as expected. Moreover, sometimes the values are not known at compile time (in designing APIs, the values can be anything the user passes to us, and its out of our control).



Issue discussion:
Very early versions of Gson actually supported inner classes. 
What do you expect to happen to the outer class reference? Ignore it silently or serialize it as well?
Note that the topic of this issue is not inner classes but anonymous classes.

Anyway, ignoring the references to the outer class seems fine (imagine a circular dependency otherwise). Gson's [documentation](https://github.com/google/gson/blob/master/UserGuide.md#finer-points-with-objects) already explicitly mentions the behaviour to fields corresponding to outer classes:
> Fields corresponding to the outer classes in inner classes, anonymous classes, and local classes are ignored and not included in serialization or deserialization.


If you don't want to implement this, fine, but at least give an exception instead of silently serializing to `null`. I just lost way too much time debugging some weird `NullPointerException`s until I realized it was due to the use of anonymous classes.

I think deserialization should be possible as an opt-in through the use of custom type adapters.
There should be some option to tell Gson to include the fields of anonymous classes, something like `toJson(obj, withAnonymousStuff = true)`.
Besides general support for serialization of anonymous classes, there are also these special cases for which it would be even more reasonable to allow serialization or deserialization:
- When the user has registered a custom `TypeAdapter` / `TypeAdapterFactory` for the type (or a supertype)
- When the user has registered a custom `InstanceCreator`, and therefore the risk of a `NullPointerException` after deserialization when accessing the enclosing class does not exist

Personally, I find the proposal mentioned in https://github.com/google/gson/issues/1510#issuecomment-597839182 to throw an exception on deserialization quite compelling, but I think it can unfortunately not be easily implemented:
- When implemented with a custom `GsonBuilder` setting, it might be necessary to differentiate between inner class, anonymous class and local class, and would therefore bloat the API
- When implemented as built-in `TypeAdapterFactory` which throws the exception on deserialization, the user could not overwrite this behavior by registering a `TypeAdapterFactory` which delegates to reflection-based deserialization (because delegation would delegate to that throwing adapter factory)
- When implemented with a built-in instance creator (or as part of the internal `ConstructorConstructor`), it would be somewhat redundant, the proper solution might be to use [`GsonBuilder.disableJdkUnsafe()`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/GsonBuilder.html#disableJdkUnsafe()). And users could also not disable this behavior / delegate to `Unsafe` instance creation without a new `GsonBuilder` setting.
- When implemented with a [`ReflectionAccessFilter`](https://javadoc.io/doc/com.google.code.gson/gson/latest/com.google.gson/com/google/gson/ReflectionAccessFilter.html) it would not be possible to differentiate between serialization and deserialization

Also, on deserialization trying to detect whether a local or anonymous class uses the enclosing instance does not seem to be possible (until recently, see [JDK-8271623](https://bugs.openjdk.org/browse/JDK-8271623)), because even when the enclosing instance is not used, the compiler seems to generate a field storing the enclosing instance anyways.

So maybe Gson should just remove restriction for local and anonymous classes, assuming that the users knows what they are doing. This would be backward incompatible, but the question is how many users really rely on Gson ignoring the values for anonymous and local classes.

Users could still achieve the old behavior by specifying an `ExclusionStrategy`, and as shown above, if users want Gson to throw exceptions, they could use multiple approaches to achieve this.

@eamonnmcmanus, what do you think about dropping this restriction for anonymous and local classes?
(we might need to find out though if there are use cases relying on this behavior)

I'm not terribly motivated to support serializing anonymous classes. It's easy enough to make a named class if you need to serialize it, and I'm concerned that the serialization could turn out to be a can of worms.

I'm more sympathetic to the notion of throwing an exception rather than serializing `null`. It seems we should be able to tell when we are about to do that? It's potentially incompatible but only for people who have been seeing these mysterious `null` values in their JSON and not cared. We could maybe have a `GsonBuilder` option or even system property for people like that.
> We could maybe have a `GsonBuilder` option or even system property for people like that.

That might not even be necessary, they can probably just register an `ExclusionStrategy` which behaves like the current built-in exclusion logic for local and anonymous classes.
Thanks for PR #2189! That enabled me to see the effect of throwing an exception here, by running the change against all of Google's internal tests. (We have thousands of non-test source files that reference the Gson API.) I found one place where a field with [this anonymous `Ticker` subclass](https://github.com/google/guava/blob/49e6b9c4a1f551d30b18483800f02de68d7ca062/guava/src/com/google/common/base/Ticker.java#L49) newly causes an exception; two places where tests were explicitly expecting `null` for an anonymous class; one place where the new exception was caught and serialized as a `potentiallyIncompleteData` property in the containing JSON object, triggering a golden-file comparison failure; and one place where a serialized listener caused hundreds of test failures. While all of these are fixable, it makes me concerned that we could be breaking users in hard-to-diagnose ways. It should be possible for people to upgrade to the latest Gson without having to solve this sort of problem.

The main alternative seems to be to support serializing anonymous classes in at least some cases, as alluded to by @Marcono1234 [above](https://github.com/google/gson/issues/1510#issuecomment-1234738385). I'm afraid that would also cause similar problems, though, unless we introduced yet _another_ `GsonBuilder` option.

We have seen a number of cases like this where we want to do the right thing but are hampered by the risk of breaking existing users. I am wondering if we should maybe introduce the notion of a "compatibility level". It might be an enum, with constants like `L1`, `L2`, and a special value `LATEST`. In your `GsonBuilder` you could say that you want the compatibility level that is current at the time you write your code, say `L2`. Then you benefit from any potentially-incompatible fixes that were current in `L2`, but not any later ones. Or you could say `LATEST` if you are prepared to fix any problems from future incompatible changes. If you say nothing then you get `L0`, which means keeping all the ugly behaviours we have today. (Perhaps the constants could have names like `V2_9_1` instead or as well.) This scheme would avoid the combinatorial explosion induced by having one `GsonBuilder` option for every incompatible change.
> I found one place where a field with this anonymous `Ticker` subclass newly causes an exception

This also highlights other flaws with the current behavior:
- Users relying on fields being excluded because they have anonymous or local class values might unintentionally serialize data when they refactor their code to not use anonymous or local classes anymore
- The field is only excluded for serialization; it is (unless the field type is a local class) still deserialized

> I am wondering if we should maybe introduce the notion of a "compatibility level"

I guess that would be possible, but I am a bit afraid that this could turn into a maintainability and troubleshooting nightmare. The effect of the current `GsonBuilder` methods is limited to a certain area, whereas these compatibility levels might affect all kinds of areas (and would still have to work properly in combination with the other `GsonBuilder` methods?).
Maybe it is a good idea to solve this, but I don't really know.
Its interesting to see why the decision to not serialize anonymous classes was taken in the first place.

As many others I just found this issue indirectly, when I used `nimbus-jose-jwt` library that uses Gson internally to serialize JWT token claims.

It happened that my claims contained `scope` claim that is `Set<String>`. Nothing special, right? Imagine my confusion when I seen that the serialization result is `null`. After some hours of debugging I realized that the reason is that some code in a different place uses Guava's `Sets.intersection(..)` utility to create a Set. Like this:

```java
Set<String> filteredScopes = Sets.intersection(requestedScopes, allowedScopes);
```

As soon as I added the result to a new `HashSet<String>`, the serialization started to work as expected.

The main problem I see here is that the serialization result depends on the conditions not always controllable by a calling party. E.g. I didn't know until now that the result of `Sets.intersection` is anonymous class. And why should I care? It is `Collection` and serialization of collections is supported. It is very strange that _supported_ depends on the implementation of the `Collection`. As a library user, I cannot always know/control what implementation of the `Collection` I'm passing to the library. Especially if I do not use the library directly, like it was in my case.

The fact that when collection implementation is not supported, this error is silently ignored, was also surprising, but I don't think this problem should be solved by changing the behavior to throwing an exception. I think the solution should be to allow serialization of any implementation of supported interfaces by default.

It could be considered as a breaking change, but here we are returning to my first question: why the decision to not serialize anonymous classes was taken in the first place? Is it really a deliberate decision or just overlooking? Are there any reason for people to rely on this specific behavior? If not, then this breaking change will only "break" some very rare cases anyway and I would rather consider it as a "bug fix".
The [comment](https://github.com/google/gson/issues/1510#issuecomment-1718047892) from @xak2000 is interesting. We probably want to continue not trying to serialize anonymous classes via reflection. But in cases where another `TypeAdapterFactory` takes precedence over `ReflectiveTypeAdapterFactory`, we shouldn't care whether the class is anonymous. In the initial example (`Sets.union`) and this one (`Sets.intersection`), `CollectionTypeAdapterFactory` would handle those anonymous classes perfectly well. So perhaps we can find a way to reorganize the logic so this can happen. We'd still want _deserialization_ to exclude anonymous classes.

Solution patch:
diff --git a/gson/src/main/java/com/google/gson/internal/Excluder.java b/gson/src/main/java/com/google/gson/internal/Excluder.java
--- a/gson/src/main/java/com/google/gson/internal/Excluder.java
+++ b/gson/src/main/java/com/google/gson/internal/Excluder.java
@@ -24,6 +24,7 @@
 import com.google.gson.annotations.Expose;
 import com.google.gson.annotations.Since;
 import com.google.gson.annotations.Until;
+import com.google.gson.internal.reflect.ReflectionHelper;
 import com.google.gson.reflect.TypeToken;
 import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.JsonWriter;
@@ -109,18 +110,20 @@ public Excluder withExclusionStrategy(
   @Override
   public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> type) {
     Class<?> rawType = type.getRawType();
-    boolean excludeClass = excludeClassChecks(rawType);
 
-    final boolean skipSerialize = excludeClass || excludeClassInStrategy(rawType, true);
-    final boolean skipDeserialize = excludeClass || excludeClassInStrategy(rawType, false);
+    final boolean skipSerialize = excludeClass(rawType, true);
+    final boolean skipDeserialize = excludeClass(rawType, false);
 
     if (!skipSerialize && !skipDeserialize) {
       return null;
     }
 
     return new TypeAdapter<T>() {
-      /** The delegate is lazily created because it may not be needed, and creating it may fail. */
-      private TypeAdapter<T> delegate;
+      /**
+       * The delegate is lazily created because it may not be needed, and creating it may fail.
+       * Field has to be {@code volatile} because {@link Gson} guarantees to be thread-safe.
+       */
+      private volatile TypeAdapter<T> delegate;
 
       @Override
       public T read(JsonReader in) throws IOException {
@@ -141,6 +144,8 @@ public void write(JsonWriter out, T value) throws IOException {
       }
 
       private TypeAdapter<T> delegate() {
+        // A race might lead to `delegate` being assigned by multiple threads but the last
+        // assignment will stick
         TypeAdapter<T> d = delegate;
         return d != null ? d : (delegate = gson.getDelegateAdapter(Excluder.this, type));
       }
@@ -168,11 +173,7 @@ public boolean excludeField(Field field, boolean serialize) {
       }
     }
 
-    if (!serializeInnerClasses && isInnerClass(field.getType())) {
-      return true;
-    }
-
-    if (isAnonymousOrNonStaticLocal(field.getType())) {
+    if (excludeClass(field.getType(), serialize)) {
       return true;
     }
 
@@ -189,7 +190,8 @@ public boolean excludeField(Field field, boolean serialize) {
     return false;
   }
 
-  private boolean excludeClassChecks(Class<?> clazz) {
+  // public for unit tests; can otherwise be private
+  public boolean excludeClass(Class<?> clazz, boolean serialize) {
     if (version != Excluder.IGNORE_VERSIONS
         && !isValidVersion(clazz.getAnnotation(Since.class), clazz.getAnnotation(Until.class))) {
       return true;
@@ -199,14 +201,24 @@ private boolean excludeClassChecks(Class<?> clazz) {
       return true;
     }
 
-    return isAnonymousOrNonStaticLocal(clazz);
-  }
-
-  public boolean excludeClass(Class<?> clazz, boolean serialize) {
-    return excludeClassChecks(clazz) || excludeClassInStrategy(clazz, serialize);
-  }
+    /*
+     * Exclude anonymous and local classes because they can have synthetic fields capturing enclosing
+     * values which makes serialization and deserialization unreliable.
+     * Don't exclude anonymous enum subclasses because enum types have a built-in adapter.
+     *
+     * Exclude only for deserialization; for serialization allow because custom adapter might be
+     * used; if no custom adapter exists reflection-based adapter otherwise excludes value.
+     *
+     * Cannot allow deserialization reliably here because some custom adapters like Collection adapter
+     * fall back to creating instances using Unsafe, which would likely lead to runtime exceptions
+     * for anonymous and local classes if they capture values.
+     */
+    if (!serialize
+        && !Enum.class.isAssignableFrom(clazz)
+        && ReflectionHelper.isAnonymousOrNonStaticLocal(clazz)) {
+      return true;
+    }
 
-  private boolean excludeClassInStrategy(Class<?> clazz, boolean serialize) {
     List<ExclusionStrategy> list = serialize ? serializationStrategies : deserializationStrategies;
     for (ExclusionStrategy exclusionStrategy : list) {
       if (exclusionStrategy.shouldSkipClass(clazz)) {
@@ -216,18 +228,8 @@ private boolean excludeClassInStrategy(Class<?> clazz, boolean serialize) {
     return false;
   }
 
-  private static boolean isAnonymousOrNonStaticLocal(Class<?> clazz) {
-    return !Enum.class.isAssignableFrom(clazz)
-        && !isStatic(clazz)
-        && (clazz.isAnonymousClass() || clazz.isLocalClass());
-  }
-
   private static boolean isInnerClass(Class<?> clazz) {
-    return clazz.isMemberClass() && !isStatic(clazz);
-  }
-
-  private static boolean isStatic(Class<?> clazz) {
-    return (clazz.getModifiers() & Modifier.STATIC) != 0;
+    return clazz.isMemberClass() && !ReflectionHelper.isStatic(clazz);
   }
 
   private boolean isValidVersion(Since since, Until until) {
diff --git a/gson/src/main/java/com/google/gson/internal/bind/ReflectiveTypeAdapterFactory.java b/gson/src/main/java/com/google/gson/internal/bind/ReflectiveTypeAdapterFactory.java
--- a/gson/src/main/java/com/google/gson/internal/bind/ReflectiveTypeAdapterFactory.java
+++ b/gson/src/main/java/com/google/gson/internal/bind/ReflectiveTypeAdapterFactory.java
@@ -78,7 +78,7 @@ public ReflectiveTypeAdapterFactory(
   }
 
   private boolean includeField(Field f, boolean serialize) {
-    return !excluder.excludeClass(f.getType(), serialize) && !excluder.excludeField(f, serialize);
+    return !excluder.excludeField(f, serialize);
   }
 
   /** first element holds the default name */
@@ -110,6 +110,31 @@ public <T> TypeAdapter<T> create(Gson gson, final TypeToken<T> type) {
       return null; // it's a primitive!
     }
 
+    // Don't allow using reflection on anonymous and local classes because synthetic fields for
+    // captured enclosing values make this unreliable
+    if (ReflectionHelper.isAnonymousOrNonStaticLocal(raw)) {
+      // This adapter just serializes and deserializes null, ignoring the actual values
+      // This is done for backward compatibility; troubleshooting-wise it might be better to throw
+      // exceptions
+      return new TypeAdapter<T>() {
+        @Override
+        public T read(JsonReader in) throws IOException {
+          in.skipValue();
+          return null;
+        }
+
+        @Override
+        public void write(JsonWriter out, T value) throws IOException {
+          out.nullValue();
+        }
+
+        @Override
+        public String toString() {
+          return "AnonymousOrNonStaticLocalClassAdapter";
+        }
+      };
+    }
+
     FilterResult filterResult =
         ReflectionAccessFilterHelper.getFilterResult(reflectionFilters, raw);
     if (filterResult == FilterResult.BLOCK_ALL) {
diff --git a/gson/src/main/java/com/google/gson/internal/reflect/ReflectionHelper.java b/gson/src/main/java/com/google/gson/internal/reflect/ReflectionHelper.java
--- a/gson/src/main/java/com/google/gson/internal/reflect/ReflectionHelper.java
+++ b/gson/src/main/java/com/google/gson/internal/reflect/ReflectionHelper.java
@@ -23,6 +23,7 @@
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
 
 public class ReflectionHelper {
 
@@ -146,6 +147,15 @@ private static void appendExecutableParameters(
     stringBuilder.append(')');
   }
 
+  public static boolean isStatic(Class<?> clazz) {
+    return Modifier.isStatic(clazz.getModifiers());
+  }
+
+  /** Returns whether the class is anonymous or a non-static local class. */
+  public static boolean isAnonymousOrNonStaticLocal(Class<?> clazz) {
+    return !isStatic(clazz) && (clazz.isAnonymousClass() || clazz.isLocalClass());
+  }
+
   /**
    * Tries making the constructor accessible, returning an exception message if this fails.
    *


Test patch:
diff --git a/gson/src/test/java/com/google/gson/ExposeAnnotationExclusionStrategyTest.java b/gson/src/test/java/com/google/gson/ExposeAnnotationExclusionStrategyTest.java
--- a/gson/src/test/java/com/google/gson/ExposeAnnotationExclusionStrategyTest.java
+++ b/gson/src/test/java/com/google/gson/ExposeAnnotationExclusionStrategyTest.java
@@ -31,38 +31,48 @@
 public class ExposeAnnotationExclusionStrategyTest {
   private Excluder excluder = Excluder.DEFAULT.excludeFieldsWithoutExposeAnnotation();
 
+  private void assertIncludesClass(Class<?> c) {
+    assertThat(excluder.excludeClass(c, true)).isFalse();
+    assertThat(excluder.excludeClass(c, false)).isFalse();
+  }
+
+  private void assertIncludesField(Field f) {
+    assertThat(excluder.excludeField(f, true)).isFalse();
+    assertThat(excluder.excludeField(f, false)).isFalse();
+  }
+
+  private void assertExcludesField(Field f) {
+    assertThat(excluder.excludeField(f, true)).isTrue();
+    assertThat(excluder.excludeField(f, false)).isTrue();
+  }
+
   @Test
   public void testNeverSkipClasses() {
-    assertThat(excluder.excludeClass(MockObject.class, true)).isFalse();
-    assertThat(excluder.excludeClass(MockObject.class, false)).isFalse();
+    assertIncludesClass(MockObject.class);
   }
 
   @Test
   public void testSkipNonAnnotatedFields() throws Exception {
     Field f = createFieldAttributes("hiddenField");
-    assertThat(excluder.excludeField(f, true)).isTrue();
-    assertThat(excluder.excludeField(f, false)).isTrue();
+    assertExcludesField(f);
   }
 
   @Test
   public void testSkipExplicitlySkippedFields() throws Exception {
     Field f = createFieldAttributes("explicitlyHiddenField");
-    assertThat(excluder.excludeField(f, true)).isTrue();
-    assertThat(excluder.excludeField(f, false)).isTrue();
+    assertExcludesField(f);
   }
 
   @Test
   public void testNeverSkipExposedAnnotatedFields() throws Exception {
     Field f = createFieldAttributes("exposedField");
-    assertThat(excluder.excludeField(f, true)).isFalse();
-    assertThat(excluder.excludeField(f, false)).isFalse();
+    assertIncludesField(f);
   }
 
   @Test
   public void testNeverSkipExplicitlyExposedAnnotatedFields() throws Exception {
     Field f = createFieldAttributes("explicitlyExposedField");
-    assertThat(excluder.excludeField(f, true)).isFalse();
-    assertThat(excluder.excludeField(f, false)).isFalse();
+    assertIncludesField(f);
   }
 
   @Test
diff --git a/gson/src/test/java/com/google/gson/InnerClassExclusionStrategyTest.java b/gson/src/test/java/com/google/gson/InnerClassExclusionStrategyTest.java
--- a/gson/src/test/java/com/google/gson/InnerClassExclusionStrategyTest.java
+++ b/gson/src/test/java/com/google/gson/InnerClassExclusionStrategyTest.java
@@ -32,28 +32,48 @@ public class InnerClassExclusionStrategyTest {
   public StaticNestedClass staticNestedClass = new StaticNestedClass();
   private Excluder excluder = Excluder.DEFAULT.disableInnerClassSerialization();
 
+  private void assertIncludesClass(Class<?> c) {
+    assertThat(excluder.excludeClass(c, true)).isFalse();
+    assertThat(excluder.excludeClass(c, false)).isFalse();
+  }
+
+  private void assertExcludesClass(Class<?> c) {
+    assertThat(excluder.excludeClass(c, true)).isTrue();
+    assertThat(excluder.excludeClass(c, false)).isTrue();
+  }
+
+  private void assertIncludesField(Field f) {
+    assertThat(excluder.excludeField(f, true)).isFalse();
+    assertThat(excluder.excludeField(f, false)).isFalse();
+  }
+
+  private void assertExcludesField(Field f) {
+    assertThat(excluder.excludeField(f, true)).isTrue();
+    assertThat(excluder.excludeField(f, false)).isTrue();
+  }
+
   @Test
   public void testExcludeInnerClassObject() {
     Class<?> clazz = innerClass.getClass();
-    assertThat(excluder.excludeClass(clazz, true)).isTrue();
+    assertExcludesClass(clazz);
   }
 
   @Test
   public void testExcludeInnerClassField() throws Exception {
     Field f = getClass().getField("innerClass");
-    assertThat(excluder.excludeField(f, true)).isTrue();
+    assertExcludesField(f);
   }
 
   @Test
   public void testIncludeStaticNestedClassObject() {
     Class<?> clazz = staticNestedClass.getClass();
-    assertThat(excluder.excludeClass(clazz, true)).isFalse();
+    assertIncludesClass(clazz);
   }
 
   @Test
   public void testIncludeStaticNestedClassField() throws Exception {
     Field f = getClass().getField("staticNestedClass");
-    assertThat(excluder.excludeField(f, true)).isFalse();
+    assertIncludesField(f);
   }
 
   @SuppressWarnings("ClassCanBeStatic")
diff --git a/gson/src/test/java/com/google/gson/VersionExclusionStrategyTest.java b/gson/src/test/java/com/google/gson/VersionExclusionStrategyTest.java
--- a/gson/src/test/java/com/google/gson/VersionExclusionStrategyTest.java
+++ b/gson/src/test/java/com/google/gson/VersionExclusionStrategyTest.java
@@ -22,6 +22,7 @@
 import com.google.gson.annotations.Since;
 import com.google.gson.annotations.Until;
 import com.google.gson.internal.Excluder;
+import java.lang.reflect.Field;
 import org.junit.Test;
 
 /**
@@ -32,44 +33,64 @@
 public class VersionExclusionStrategyTest {
   private static final double VERSION = 5.0D;
 
+  private static void assertIncludesClass(Excluder excluder, Class<?> c) {
+    assertThat(excluder.excludeClass(c, true)).isFalse();
+    assertThat(excluder.excludeClass(c, false)).isFalse();
+  }
+
+  private static void assertExcludesClass(Excluder excluder, Class<?> c) {
+    assertThat(excluder.excludeClass(c, true)).isTrue();
+    assertThat(excluder.excludeClass(c, false)).isTrue();
+  }
+
+  private static void assertIncludesField(Excluder excluder, Field f) {
+    assertThat(excluder.excludeField(f, true)).isFalse();
+    assertThat(excluder.excludeField(f, false)).isFalse();
+  }
+
+  private static void assertExcludesField(Excluder excluder, Field f) {
+    assertThat(excluder.excludeField(f, true)).isTrue();
+    assertThat(excluder.excludeField(f, false)).isTrue();
+  }
+
   @Test
   public void testSameVersion() throws Exception {
     Excluder excluder = Excluder.DEFAULT.withVersion(VERSION);
-    assertThat(excluder.excludeClass(MockClassSince.class, true)).isFalse();
-    assertThat(excluder.excludeField(MockClassSince.class.getField("someField"), true)).isFalse();
+    assertIncludesClass(excluder, MockClassSince.class);
+    assertIncludesField(excluder, MockClassSince.class.getField("someField"));
 
     // Until version is exclusive
-    assertThat(excluder.excludeClass(MockClassUntil.class, true)).isTrue();
-    assertThat(excluder.excludeField(MockClassUntil.class.getField("someField"), true)).isTrue();
+    assertExcludesClass(excluder, MockClassUntil.class);
+    assertExcludesField(excluder, MockClassUntil.class.getField("someField"));
 
-    assertThat(excluder.excludeClass(MockClassBoth.class, true)).isFalse();
-    assertThat(excluder.excludeField(MockClassBoth.class.getField("someField"), true)).isFalse();
+    assertIncludesClass(excluder, MockClassBoth.class);
+    assertIncludesField(excluder, MockClassBoth.class.getField("someField"));
   }
 
   @Test
   public void testNewerVersion() throws Exception {
     Excluder excluder = Excluder.DEFAULT.withVersion(VERSION + 5);
-    assertThat(excluder.excludeClass(MockClassSince.class, true)).isFalse();
-    assertThat(excluder.excludeField(MockClassSince.class.getField("someField"), true)).isFalse();
+    assertIncludesClass(excluder, MockClassSince.class);
+    assertIncludesField(excluder, MockClassSince.class.getField("someField"));
 
-    assertThat(excluder.excludeClass(MockClassUntil.class, true)).isTrue();
-    assertThat(excluder.excludeField(MockClassUntil.class.getField("someField"), true)).isTrue();
+    assertExcludesClass(excluder, MockClassUntil.class);
+    assertExcludesField(excluder, MockClassUntil.class.getField("someField"));
 
-    assertThat(excluder.excludeClass(MockClassBoth.class, true)).isTrue();
-    assertThat(excluder.excludeField(MockClassBoth.class.getField("someField"), true)).isTrue();
+    assertExcludesClass(excluder, MockClassBoth.class);
+    assertExcludesField(excluder, MockClassBoth.class.getField("someField"));
   }
 
   @Test
   public void testOlderVersion() throws Exception {
     Excluder excluder = Excluder.DEFAULT.withVersion(VERSION - 5);
-    assertThat(excluder.excludeClass(MockClassSince.class, true)).isTrue();
-    assertThat(excluder.excludeField(MockClassSince.class.getField("someField"), true)).isTrue();
+    assertExcludesClass(excluder, MockClassSince.class);
+    assertExcludesField(excluder, MockClassSince.class.getField("someField"));
 
-    assertThat(excluder.excludeClass(MockClassUntil.class, true)).isFalse();
-    assertThat(excluder.excludeField(MockClassUntil.class.getField("someField"), true)).isFalse();
+    assertIncludesClass(excluder, MockClassUntil.class);
+    assertIncludesField(excluder, MockClassUntil.class.getField("someField"));
 
-    assertThat(excluder.excludeClass(MockClassBoth.class, true)).isTrue();
-    assertThat(excluder.excludeField(MockClassBoth.class.getField("someField"), true)).isTrue();
+    assertExcludesClass(excluder, MockClassBoth.class);
+    assertExcludesField(excluder, MockClassBoth.class.getField("someField"));
   }
 
   @Since(VERSION)
diff --git a/gson/src/test/java/com/google/gson/functional/EnumTest.java b/gson/src/test/java/com/google/gson/functional/EnumTest.java
--- a/gson/src/test/java/com/google/gson/functional/EnumTest.java
+++ b/gson/src/test/java/com/google/gson/functional/EnumTest.java
@@ -127,10 +127,13 @@ public void testEnumSubclass() {
     assertThat(gson.toJson(EnumSet.allOf(Roshambo.class)))
         .isEqualTo("[\"ROCK\",\"PAPER\",\"SCISSORS\"]");
     assertThat(gson.fromJson("\"ROCK\"", Roshambo.class)).isEqualTo(Roshambo.ROCK);
-    assertThat(EnumSet.allOf(Roshambo.class))
-        .isEqualTo(
-            gson.fromJson(
-                "[\"ROCK\",\"PAPER\",\"SCISSORS\"]", new TypeToken<Set<Roshambo>>() {}.getType()));
+    Set<Roshambo> deserialized =
+        gson.fromJson("[\"ROCK\",\"PAPER\",\"SCISSORS\"]", new TypeToken<>() {});
+    assertThat(deserialized).isEqualTo(EnumSet.allOf(Roshambo.class));
+
+    // A bit contrived, but should also work if explicitly deserializing using anonymous enum
+    // subclass
+    assertThat(gson.fromJson("\"ROCK\"", Roshambo.ROCK.getClass())).isEqualTo(Roshambo.ROCK);
   }
 
   @Test
@@ -145,11 +148,9 @@ public void testEnumSubclassWithRegisteredTypeAdapter() {
     assertThat(gson.toJson(EnumSet.allOf(Roshambo.class)))
         .isEqualTo("[\"123ROCK\",\"123PAPER\",\"123SCISSORS\"]");
     assertThat(gson.fromJson("\"123ROCK\"", Roshambo.class)).isEqualTo(Roshambo.ROCK);
-    assertThat(EnumSet.allOf(Roshambo.class))
-        .isEqualTo(
-            gson.fromJson(
-                "[\"123ROCK\",\"123PAPER\",\"123SCISSORS\"]",
-                new TypeToken<Set<Roshambo>>() {}.getType()));
+    Set<Roshambo> deserialized =
+        gson.fromJson("[\"123ROCK\",\"123PAPER\",\"123SCISSORS\"]", new TypeToken<>() {});
+    assertThat(deserialized).isEqualTo(EnumSet.allOf(Roshambo.class));
   }
 
   @Test
diff --git a/gson/src/test/java/com/google/gson/functional/ObjectTest.java b/gson/src/test/java/com/google/gson/functional/ObjectTest.java
--- a/gson/src/test/java/com/google/gson/functional/ObjectTest.java
+++ b/gson/src/test/java/com/google/gson/functional/ObjectTest.java
@@ -24,10 +24,13 @@
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.gson.InstanceCreator;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonIOException;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
 import com.google.gson.JsonSerializationContext;
 import com.google.gson.JsonSerializer;
 import com.google.gson.common.TestTypes.ArrayOfObjects;
@@ -381,11 +384,14 @@ public void testAnonymousLocalClassesSerialization() {
                   // empty anonymous class
                 }))
         .isEqualTo("null");
+
+    class Local {}
+    assertThat(gson.toJson(new Local())).isEqualTo("null");
   }
 
   @Test
   public void testAnonymousLocalClassesCustomSerialization() {
-    gson =
+    Gson gson =
         new GsonBuilder()
             .registerTypeHierarchyAdapter(
                 ClassWithNoFields.class,
@@ -393,7 +399,7 @@ public void testAnonymousLocalClassesCustomSerialization() {
                   @Override
                   public JsonElement serialize(
                       ClassWithNoFields src, Type typeOfSrc, JsonSerializationContext context) {
-                    return new JsonObject();
+                    return new JsonPrimitive("custom-value");
                   }
                 })
             .create();
@@ -403,7 +409,59 @@ public JsonElement serialize(
                 new ClassWithNoFields() {
                   // empty anonymous class
                 }))
-        .isEqualTo("null");
+        .isEqualTo("\"custom-value\"");
+
+    class Local {}
+    gson =
+        new GsonBuilder()
+            .registerTypeAdapter(
+                Local.class,
+                new JsonSerializer<Local>() {
+                  @Override
+                  public JsonElement serialize(
+                      Local src, Type typeOfSrc, JsonSerializationContext context) {
+                    return new JsonPrimitive("custom-value");
+                  }
+                })
+            .create();
+    assertThat(gson.toJson(new Local())).isEqualTo("\"custom-value\"");
+  }
+
+  @Test
+  public void testAnonymousLocalClassesCustomDeserialization() {
+    Gson gson =
+        new GsonBuilder()
+            .registerTypeHierarchyAdapter(
+                ClassWithNoFields.class,
+                new JsonDeserializer<ClassWithNoFields>() {
+                  @Override
+                  public ClassWithNoFields deserialize(
+                      JsonElement json, Type typeOfT, JsonDeserializationContext context) {
+                    return new ClassWithNoFields();
+                  }
+                })
+            .create();
+
+    assertThat(gson.fromJson("{}", ClassWithNoFields.class)).isNotNull();
+    Class<?> anonymousClass = new ClassWithNoFields() {}.getClass();
+    // Custom deserializer is ignored
+    assertThat(gson.fromJson("{}", anonymousClass)).isNull();
+
+    class Local {}
+    gson =
+        new GsonBuilder()
+            .registerTypeAdapter(
+                Local.class,
+                new JsonDeserializer<Local>() {
+                  @Override
+                  public Local deserialize(
+                      JsonElement json, Type typeOfT, JsonDeserializationContext context) {
+                    throw new AssertionError("should not be called");
+                  }
+                })
+            .create();
+    // Custom deserializer is ignored
+    assertThat(gson.fromJson("{}", Local.class)).isNull();
   }
 
   @Test