#3122·gson

`TypeAdapterRuntimeTypeWrapper` eagerly resolves the runtime-type adapter, so a failure building an adapter it would have discarded escapes to the caller

Author: d-williamCreated Sep 15, 2026Updated Sep 17, 2026
Labelsbug

Gson version

2.14.0 (also reproduced on 2.12.1, 2.9.0 and 2.8.9)

Java version

Reproduced on OpenJDK 21 and OpenJDK 26. Any JDK with the module system should be affected.

Description

When a value is serialized as an element of an array or a collection, Gson wraps the component's TypeAdapter in TypeAdapterRuntimeTypeWrapper. On write, that wrapper resolves an adapter for the value's runtime type and only afterwards decides which of the two adapters to use:

java
// com/google/gson/internal/bind/TypeAdapterRuntimeTypeWrapper.java
TypeAdapter<T> chosen = delegate;
Type runtimeType = getRuntimeTypeIfMoreSpecific(type, value);
if (runtimeType != type) {
  TypeAdapter<T> runtimeTypeAdapter = (TypeAdapter<T>) context.getAdapter(TypeToken.get(runtimeType));
  if (!(runtimeTypeAdapter instanceof ReflectiveTypeAdapterFactory.Adapter)) {
    chosen = runtimeTypeAdapter;
  } else if (!isReflective(delegate)) {
    chosen = delegate;              // <-- runtimeTypeAdapter is discarded here
  } else {
    chosen = runtimeTypeAdapter;
  }
}
chosen.write(out, value);

The context.getAdapter(...) call is unconditional. When no adapter is registered for the runtime type, Gson falls back to ReflectiveTypeAdapterFactory, whose construction throws JsonIOException if a field of that class cannot be made accessible — for instance any class in a package that java.base does not open.

In the else if (!isReflective(delegate)) branch the result of that call is thrown away: the user-registered adapter for the declared type is used instead. The exception is therefore raised while computing a value the method has already decided not to use.

The practical consequence is that registering an adapter for an interface is enough to serialize a value directly, but not to serialize the same value inside an array or a collection, as soon as the implementation class is one Gson cannot reflect over. This makes java.lang.reflect.Type graphs (whose implementations live in sun.reflect.generics.reflectiveObjects) impossible to serialize without registering an adapter for every internal JDK class, even though a perfectly good adapter for the declared interface is registered.

Reproduction

java
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.TypeAdapter;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;

import java.io.IOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.WildcardType;
import java.util.Arrays;
import java.util.List;

public class GsonRuntimeTypeLookupBug {

    static class Holder {
        List<? extends Number> field;
    }

    /** A trivial adapter for WildcardType. It never uses reflection. */
    static final TypeAdapter<WildcardType> WILDCARD_ADAPTER = new TypeAdapter<WildcardType>() {
        @Override
        public void write(JsonWriter out, WildcardType value) throws IOException {
            out.value(value == null ? null : value.getTypeName());
        }

        @Override
        public WildcardType read(JsonReader in) throws IOException {
            in.nextString();
            return null;
        }
    };

    public static void main(String[] args) throws Exception {
        ParameterizedType listOfWildcard =
                (ParameterizedType) Holder.class.getDeclaredField("field").getGenericType();
        WildcardType wildcard = (WildcardType) listOfWildcard.getActualTypeArguments()[0];
        // wildcard.getClass() is sun.reflect.generics.reflectiveObjects.WildcardTypeImpl

        Gson gson = new GsonBuilder()
                .registerTypeAdapter(WildcardType.class, WILDCARD_ADAPTER)
                .create();

        // works
        System.out.println(gson.toJson(wildcard, WildcardType.class));

        // throws
        System.out.println(gson.toJson(new WildcardType[]{wildcard}, WildcardType[].class));

        // throws as well
        System.out.println(gson.toJson(Arrays.asList(wildcard),
                new TypeToken<List<WildcardType>>() {}.getType()));
    }
}

Expected behaviour

All three calls serialize the wildcard with WILDCARD_ADAPTER, since it is registered for the declared type and is not reflective — which is exactly what TypeAdapterRuntimeTypeWrapper decides in its else if (!isReflective(delegate)) branch.

Actual behaviour

The first call succeeds. The other two fail:

"? extends java.lang.Number"

Exception in thread "main" com.google.gson.JsonIOException: Failed making field
'sun.reflect.generics.reflectiveObjects.WildcardTypeImpl#upperBounds' accessible;
either increase its visibility or write a custom TypeAdapter for its declaring type.
See https://github.com/google/gson/blob/main/Troubleshooting.md#reflection-inaccessible
	at com.google.gson.internal.reflect.ReflectionHelper.makeAccessible(ReflectionHelper.java:52)
	at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.getBoundFields(...)
	at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.create(...)
	at com.google.gson.Gson.getAdapter(Gson.java:557)
	at com.google.gson.internal.bind.TypeAdapterRuntimeTypeWrapper.write(...)

The error message is also misleading here: it advises writing a custom TypeAdapter for the declaring type, but one is registered for the declared interface, and the internal JDK class the message names is not something a user can reasonably register an adapter for.

On Gson 2.8.9 the same reproduction fails with the underlying java.lang.reflect.InaccessibleObjectException instead, so the behaviour predates the JsonIOException wrapping added in 2.9.0.

Suggested fix

Resolve the runtime-type adapter only when its outcome can actually be used, or tolerate its failure when the declared-type delegate is going to be preferred anyway. For example:

java
TypeAdapter<T> chosen = delegate;
Type runtimeType = getRuntimeTypeIfMoreSpecific(type, value);
if (runtimeType != type) {
  TypeAdapter<T> runtimeTypeAdapter = null;
  try {
    runtimeTypeAdapter = (TypeAdapter<T>) context.getAdapter(TypeToken.get(runtimeType));
  } catch (JsonIOException e) {
    // No usable adapter for the runtime type. Only rethrow if the delegate cannot serve either,
    // i.e. if we would have chosen the runtime type adapter.
    if (isReflective(delegate)) {
      throw e;
    }
  }
  if (runtimeTypeAdapter != null) {
    ...unchanged selection...
  }
}
chosen.write(out, value);

This keeps the documented order of preference intact and only changes what happens when the third preference — the reflective adapter for the runtime type — cannot be built at all.

Workaround

Registering the adapter with registerTypeHierarchyAdapter instead of registerTypeAdapter avoids the problem, because the lookup for the runtime type then returns the user adapter rather than a reflective one:

java
Gson gson = new GsonBuilder()
        .registerTypeHierarchyAdapter(WildcardType.class, WILDCARD_ADAPTER)
        .create();

Both the array and the collection then serialize as ["? extends java.lang.Number"].

The equivalent workaround for a TypeAdapterFactory is to match assignable types rather than the exact TypeToken, so that the factory also answers for the runtime implementation class.