You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@commons.apache.org by gg...@apache.org on 2026/10/15 20:03:57 UTC

(commons-lang) 02/04: Follow up: Fix TypeUtils.isAssignable rejecting Class to Class assignments (#1820)

This is an automated email from the ASF dual-hosted git repository.

garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git

commit 110683aa672b874d7ccecc1b1e89baf7774dd3c4
Author: Gary Gregory <ga...@gmail.com>
AuthorDate: Thu Oct 15 15:42:22 2026 -0400

    Follow up: Fix TypeUtils.isAssignable rejecting Class<X> to Class<?>
    assignments
    (#1820)
    
    Fix nested type substitution in TypeUtils assignability.
    
    Substitute inherited generic array components and preserve external type
    variables nested in wildcard bounds when traversing parent types. Remove
    the redundant assignment-map snapshot and correct the Javadoc.
    
    Add regression coverage for array substitution and upper and lower
    wildcard bounds.
---
 .../apache/commons/lang3/reflect/TypeUtils.java    | 122 ++++++++-------
 .../commons/lang3/reflect/TypeUtilsTest.java       | 164 ++++++++++++---------
 2 files changed, 168 insertions(+), 118 deletions(-)

diff --git a/src/main/java/org/apache/commons/lang3/reflect/TypeUtils.java b/src/main/java/org/apache/commons/lang3/reflect/TypeUtils.java
index f56db9e4e..173d99c33 100644
--- a/src/main/java/org/apache/commons/lang3/reflect/TypeUtils.java
+++ b/src/main/java/org/apache/commons/lang3/reflect/TypeUtils.java
@@ -863,7 +863,7 @@ private static Map<TypeVariable<?>, Type> getTypeArguments(final ParameterizedTy
             final ParameterizedType parameterizedParentType = (ParameterizedType) parentType;
             final Type[] parentTypeArgs = parameterizedParentType.getActualTypeArguments().clone();
             for (int i = 0; i < parentTypeArgs.length; i++) {
-                final Type unrolled = unrollVariables(typeVarAssigns, parentTypeArgs[i]);
+                final Type unrolled = unrollVariables(typeVarAssigns, parentTypeArgs[i], new HashSet<>(), newIdentitySet(), true);
                 if (unrolled != null) {
                     parentTypeArgs[i] = unrolled;
                 }
@@ -1093,7 +1093,7 @@ private static boolean isAssignable(final Type type, final GenericArrayType toGe
      * @param toParameterizedType The target parameterized type.
      * @param typeVarAssigns      A map with type variables.
      * @param allowUnchecked      Whether unchecked conversions are allowed.
-     * @return {@code true} if {@code type} is assignable to {@code toType}.
+     * @return {@code true} if {@code type} is assignable to {@code toParameterizedType}.
      */
     private static boolean isAssignable(final Type type, final ParameterizedType toParameterizedType, final Map<TypeVariable<?>, Type> typeVarAssigns,
             final boolean allowUnchecked) {
@@ -1141,13 +1141,8 @@ private static boolean isAssignable(final Type type, final ParameterizedType toP
                         return false;
                     }
                     final Map<TypeVariable<?>, Type> fromAssignments = getTypeArguments(fromType);
-                    // Preserve externally declared variables when substituting the parent type.
-                    for (final Type argument : new HashMap<>(fromAssignments).values()) {
-                        if (argument instanceof TypeVariable<?>) {
-                            fromAssignments.putIfAbsent((TypeVariable<?>) argument, argument);
-                        }
-                    }
-                    final Type parent = unrollVariables(fromAssignments, getClosestParentType(fromClass, toClass));
+                    // Preserve unmapped variables, including those nested in wildcard bounds, when substituting the parent type.
+                    final Type parent = unrollVariables(fromAssignments, getClosestParentType(fromClass, toClass), new HashSet<>(), newIdentitySet(), true);
                     return isAssignable(parent, toParameterizedType, typeVarAssigns, false);
                 }
             }
@@ -1204,36 +1199,6 @@ && isAssignableToClassWildcard(fromTypeArg, (WildcardType) toTypeArg, typeVarAss
         return true;
     }
 
-    /**
-     * Tests whether the type argument {@code fromTypeArg} of a {@code Class} parameterized type satisfies the wildcard type
-     * argument of a target {@code Class} parameterized type. Unchecked conversion does not apply inside a type argument,
-     * including when checking the upper or lower bounds of a wildcard.
-     *
-     * @param fromTypeArg    the subject type argument.
-     * @param wildcardType   the target wildcard type argument.
-     * @param typeVarAssigns a map with type variables.
-     * @return {@code true} if {@code fromTypeArg} is contained by {@code wildcardType}.
-     */
-    private static boolean isAssignableToClassWildcard(final Type fromTypeArg, final WildcardType wildcardType,
-            final Map<TypeVariable<?>, Type> typeVarAssigns) {
-        return fromTypeArg != null && isAssignable(fromTypeArg, wildcardType, typeVarAssigns, false);
-    }
-
-    /**
-     * Tests whether {@code type} is a raw generic class or a non-static member of a raw generic class.
-     *
-     * @param type the type to test.
-     * @return {@code true} if {@code type} is a raw generic class.
-     */
-    private static boolean isRawGenericClass(final Type type) {
-        if (!(type instanceof Class<?>)) {
-            return false;
-        }
-        final Class<?> cls = (Class<?>) type;
-        return cls.getTypeParameters().length > 0
-                || !Modifier.isStatic(cls.getModifiers()) && isRawGenericClass(cls.getDeclaringClass());
-    }
-
     /**
      * Tests if the subject type may be implicitly cast to the target type following the Java generics rules. If both types are {@link Class} objects, the
      * method returns the result of {@link ClassUtils#isAssignable(Class, Class)}.
@@ -1403,6 +1368,21 @@ private static boolean isAssignable(final Type type, final WildcardType toWildca
         return true;
     }
 
+    /**
+     * Tests whether the type argument {@code fromTypeArg} of a {@code Class} parameterized type satisfies the wildcard type
+     * argument of a target {@code Class} parameterized type. Unchecked conversion does not apply inside a type argument,
+     * including when checking the upper or lower bounds of a wildcard.
+     *
+     * @param fromTypeArg    the subject type argument.
+     * @param wildcardType   the target wildcard type argument.
+     * @param typeVarAssigns a map with type variables.
+     * @return {@code true} if {@code fromTypeArg} is contained by {@code wildcardType}.
+     */
+    private static boolean isAssignableToClassWildcard(final Type fromTypeArg, final WildcardType wildcardType,
+            final Map<TypeVariable<?>, Type> typeVarAssigns) {
+        return fromTypeArg != null && isAssignable(fromTypeArg, wildcardType, typeVarAssigns, false);
+    }
+
     /**
      * Tests whether the class contains a cyclical reference in the qualified name of a class. If any of the type parameters of A class is extending X class
      * which is in scope of A class, then it forms cycle.
@@ -1435,6 +1415,21 @@ public static boolean isInstance(final Object value, final Type type) {
         return value == null ? !(type instanceof Class<?>) || !((Class<?>) type).isPrimitive() : isAssignable(value.getClass(), type, null);
     }
 
+    /**
+     * Tests whether {@code type} is a raw generic class or a non-static member of a raw generic class.
+     *
+     * @param type the type to test.
+     * @return {@code true} if {@code type} is a raw generic class.
+     */
+    private static boolean isRawGenericClass(final Type type) {
+        if (!(type instanceof Class<?>)) {
+            return false;
+        }
+        final Class<?> cls = (Class<?>) type;
+        return cls.getTypeParameters().length > 0
+                || !Modifier.isStatic(cls.getModifiers()) && isRawGenericClass(cls.getDeclaringClass());
+    }
+
     /**
      * Tests whether two type arrays hold the very same types, compared by identity so that no {@code equals()} or {@code hashCode()} of a custom {@link Type}
      * is called.
@@ -1827,17 +1822,19 @@ private static String typeVariableToString(final TypeVariable<?> typeVariable) {
     /**
      * Unrolls variables in a type bounds array, preserving cycle state.
      *
-     * @param typeArguments assignments {@link Map}.
-     * @param bounds        in which to expand variables.
-     * @param visited       set of visited type variables for cycle detection.
-     * @param unrolling     identity-based types currently being unrolled to prevent recursion cycles.
+     * @param typeArguments    assignments {@link Map}.
+     * @param bounds           in which to expand variables.
+     * @param visited          set of visited type variables for cycle detection.
+     * @param unrolling        identity-based types currently being unrolled to prevent recursion cycles.
+     * @param preserveUnmapped whether to preserve variables without assignments.
      * @return {@code bounds} with any variables reassigned.
      */
-    private static Type[] unrollBounds(final Map<TypeVariable<?>, Type> typeArguments, final Type[] bounds, final Set<TypeVariable<?>> visited, final Set<Type> unrolling) {
+    private static Type[] unrollBounds(final Map<TypeVariable<?>, Type> typeArguments, final Type[] bounds, final Set<TypeVariable<?>> visited, final Set<Type> unrolling,
+            final boolean preserveUnmapped) {
         Type[] result = bounds;
         int i = 0;
         for (; i < result.length; i++) {
-            final Type unrolled = unrollVariables(typeArguments, result[i], visited, unrolling);
+            final Type unrolled = unrollVariables(typeArguments, result[i], visited, unrolling, preserveUnmapped);
             if (unrolled == null) {
                 result = ArrayUtils.remove(result, i--);
             } else {
@@ -1869,6 +1866,9 @@ private static Type unrollVariableAssignments(TypeVariable<?> typeVariable, fina
     /**
      * Gets a type representing {@code type} with variable assignments "unrolled."
      *
+     * <p>Substitutes variables in parameterized types, wildcard bounds, and generic array components. If an array component resolves to a class, the result
+     * is the corresponding array class.</p>
+     *
      * @param typeArguments as from {@link TypeUtils#getTypeArguments(Type, Class)}.
      * @param type          The type to unroll variable assignments for.
      * @return Type.
@@ -1878,22 +1878,39 @@ public static Type unrollVariables(Map<TypeVariable<?>, Type> typeArguments, fin
         if (typeArguments == null) {
             typeArguments = Collections.emptyMap();
         }
-        return unrollVariables(typeArguments, type, new HashSet<>(), newIdentitySet());
+        return unrollVariables(typeArguments, type, new HashSet<>(), newIdentitySet(), false);
     }
 
-    private static Type unrollVariables(final Map<TypeVariable<?>, Type> typeArguments, final Type type, final Set<TypeVariable<?>> visited, final Set<Type> unrolling) {
+    private static Type unrollVariables(final Map<TypeVariable<?>, Type> typeArguments, final Type type, final Set<TypeVariable<?>> visited, final Set<Type> unrolling,
+            final boolean preserveUnmapped) {
         if (type instanceof TypeVariable<?>) {
             final TypeVariable<?> var = (TypeVariable<?>) type;
             if (!visited.add(var)) {
                 return var;
             }
             try {
-                return unrollVariables(typeArguments, typeArguments.get(type), visited, unrolling);
+                final Type assignment = typeArguments.get(type);
+                return assignment == null && preserveUnmapped ? var : unrollVariables(typeArguments, assignment, visited, unrolling, preserveUnmapped);
             } finally {
                 // Only guard against cycles: the same variable may legitimately occur more than once in a type.
                 visited.remove(var);
             }
         }
+        if (type instanceof GenericArrayType) {
+            if (!unrolling.add(type)) {
+                return type;
+            }
+            try {
+                final Type component = ((GenericArrayType) type).getGenericComponentType();
+                final Type unrolled = unrollVariables(typeArguments, component, visited, unrolling, preserveUnmapped);
+                if (unrolled == null || unrolled == component) {
+                    return type;
+                }
+                return unrolled instanceof Class<?> ? Array.newInstance((Class<?>) unrolled, 0).getClass() : genericArrayType(unrolled);
+            } finally {
+                unrolling.remove(type);
+            }
+        }
         if (type instanceof ParameterizedType) {
             final ParameterizedType p = (ParameterizedType) type;
             if (hasCyclicOwnerChain(p) || !unrolling.add(p)) {
@@ -1904,14 +1921,15 @@ private static Type unrollVariables(final Map<TypeVariable<?>, Type> typeArgumen
                 final Type[] unrolledArgs = args.clone();
                 boolean changed = false;
                 for (int i = 0; i < args.length; i++) {
-                    final Type unrolled = unrollVariables(typeArguments, args[i], visited, unrolling);
+                    final Type unrolled = unrollVariables(typeArguments, args[i], visited, unrolling, preserveUnmapped);
                     if (unrolled != null && unrolled != args[i]) {
                         unrolledArgs[i] = unrolled;
                         changed = true;
                     }
                 }
                 final Type owner = p.getOwnerType();
-                final Type unrolledOwner = owner != null && !(owner instanceof Class<?>) ? unrollVariables(typeArguments, owner, visited, unrolling) : owner;
+                final Type unrolledOwner = owner != null && !(owner instanceof Class<?>)
+                        ? unrollVariables(typeArguments, owner, visited, unrolling, preserveUnmapped) : owner;
                 if (!changed && unrolledOwner == owner) {
                     return p;
                 }
@@ -1928,8 +1946,8 @@ private static Type unrollVariables(final Map<TypeVariable<?>, Type> typeArgumen
             try {
                 final Type[] upperBounds = wild.getUpperBounds();
                 final Type[] lowerBounds = wild.getLowerBounds();
-                final Type[] unrolledUpperBounds = unrollBounds(typeArguments, upperBounds.clone(), visited, unrolling);
-                final Type[] unrolledLowerBounds = unrollBounds(typeArguments, lowerBounds.clone(), visited, unrolling);
+                final Type[] unrolledUpperBounds = unrollBounds(typeArguments, upperBounds.clone(), visited, unrolling, preserveUnmapped);
+                final Type[] unrolledLowerBounds = unrollBounds(typeArguments, lowerBounds.clone(), visited, unrolling, preserveUnmapped);
                 if (isSameTypes(upperBounds, unrolledUpperBounds) && isSameTypes(lowerBounds, unrolledLowerBounds)) {
                     return wild;
                 }
diff --git a/src/test/java/org/apache/commons/lang3/reflect/TypeUtilsTest.java b/src/test/java/org/apache/commons/lang3/reflect/TypeUtilsTest.java
index f68f0175d..284ddddea 100644
--- a/src/test/java/org/apache/commons/lang3/reflect/TypeUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/reflect/TypeUtilsTest.java
@@ -225,15 +225,19 @@ public interface And<K, V> extends This<Number, Number> {
         // empty
     }
 
-    public static class ClassWithSuperClassWithGenericType extends ArrayList<Object> {
+    private static class ArrayValues<T> extends ArrayList<T[]> {
         private static final long serialVersionUID = 1L;
-
-        public static <U> Iterable<U> methodWithGenericReturnType() {
-            return null;
-        }
     }
 
     private static class ClassWildcardTypes<T extends List, U, V extends List<String>, W extends U> {
+        Class<ArrayValues<String>> arrayValues;
+        Class<ArrayValues<U>> variableArrayValues;
+        Class<? extends List<String[]>> extendsStringArrays;
+        Class<? extends List<U[]>> extendsVariableArrays;
+        Class<ArrayList<? extends U>> upperVariableValues;
+        Class<ArrayList<? super U>> lowerVariableValues;
+        Class<? extends List<? extends U>> extendsUpperVariableValues;
+        Class<? extends List<? super U>> extendsLowerVariableValues;
         Class<List<String>> strings;
         Class<List> rawList;
         Class<ArrayList<String>> stringArrayList;
@@ -276,6 +280,14 @@ private static class ClassWildcardTypes<T extends List, U, V extends List<String
         Class<? super W> superDependentVariable;
     }
 
+    public static class ClassWithSuperClassWithGenericType extends ArrayList<Object> {
+        private static final long serialVersionUID = 1L;
+
+        public static <U> Iterable<U> methodWithGenericReturnType() {
+            return null;
+        }
+    }
+
     /**
      * A custom {@link ParameterizedType} with a structural {@code hashCode()}, whose owner and type arguments can be set after construction so that tests can
      * create cycles.
@@ -331,6 +343,14 @@ public String toString() {
         }
     }
 
+    private static class GenericNumberOwner<T> {
+        class Member implements TestIF<Number> {
+        }
+
+        static class StaticMember implements TestIF<Number> {
+        }
+    }
+
     static class LexOrdering<T> extends MyOrdering<Iterable<T>> implements Serializable {
         private static final long serialVersionUID = 1L;
     }
@@ -363,6 +383,9 @@ static class MyNonTransientException extends MyException {
     static class MyOrdering<T> implements MyComparator<T> {
     }
 
+    private static class NumberTestImpl implements TestIF<Number> {
+    }
+
     public class Other<T> implements This<String, T> {
         // empty
     }
@@ -394,17 +417,6 @@ class Deep<V> {
         Map<T, T> map;
     }
 
-    private static class GenericNumberOwner<T> {
-        class Member implements TestIF<Number> {
-        }
-
-        static class StaticMember implements TestIF<Number> {
-        }
-    }
-
-    private static class NumberTestImpl implements TestIF<Number> {
-    }
-
     private static class ParameterizedRawTestImpl<T> extends TestImpl {
     }
 
@@ -474,6 +486,13 @@ public static <T extends Comparable<? extends T>> T stub3() {
 
     static Stream<Arguments> testIsAssignable_ClassWildcardBounds() {
         return Stream.of(
+                Arguments.of("arrayValues", "extendsStringArrays", true),
+                Arguments.of("variableArrayValues", "extendsVariableArrays", true),
+                Arguments.of("arrayValues", "extendsVariableArrays", false),
+                Arguments.of("upperVariableValues", "extendsUpperVariableValues", true),
+                Arguments.of("lowerVariableValues", "extendsLowerVariableValues", true),
+                Arguments.of("upperVariableValues", "extendsLowerVariableValues", false),
+                Arguments.of("lowerVariableValues", "extendsUpperVariableValues", false),
                 Arguments.of("extendsRawList", "extendsStrings", false),
                 Arguments.of("extendsRawList", "extendsUnknownList", false),
                 Arguments.of("rawBound", "extendsStrings", false),
@@ -1195,6 +1214,35 @@ void testIsArrayTypeClasses() {
         assertFalse(TypeUtils.isArrayType(String.class));
     }
 
+    @ParameterizedTest(name = "{0} -> {1}: {2}")
+    @MethodSource
+    void testIsAssignable_ClassWildcardBounds(final String fromField, final String toField, final boolean expected) throws NoSuchFieldException {
+        final Type from = ClassWildcardTypes.class.getDeclaredField(fromField).getGenericType();
+        final Type to = ClassWildcardTypes.class.getDeclaredField(toField).getGenericType();
+        assertEquals(expected, TypeUtils.isAssignable(from, to), () -> from + " -> " + to);
+    }
+
+    @Test
+    void testIsAssignable_ClassWithBoundedWildcardObject() {
+        final Type to = TypeUtils.parameterize(Class.class, TypeUtils.wildcardType().withUpperBounds(Object.class).build());
+        assertTrue(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, String.class), to),
+                "Class<String> should be assignable to Class<? extends Object>");
+    }
+
+    @Test
+    void testIsAssignable_ClassWithLowerBoundedWildcard() {
+        final Type to = TypeUtils.parameterize(Class.class, TypeUtils.wildcardType().withLowerBounds(Integer.class).build());
+        assertTrue(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, Number.class), to),
+                "Class<Number> should be assignable to Class<? super Integer>");
+        assertTrue(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, TypeUtils.wildcardType().withLowerBounds(Number.class).build()), to),
+                "Class<? super Number> should be assignable to Class<? super Integer>");
+        assertFalse(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, String.class), to),
+                "Class<String> should not be assignable to Class<? super Integer>");
+        assertTrue(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, List.class),
+                TypeUtils.parameterize(Class.class, TypeUtils.wildcardType().withLowerBounds(TypeUtils.parameterize(List.class, String.class)).build())),
+                "Class<List> should be assignable to Class<? super List<String>>");
+    }
+
     @Test
     void testIsAssignable_ClassWithParameterizedType1() {
         final ParameterizedType topre1 = TypeUtils.parameterize(TestIF.class, TypeUtils.wildcardType().build());
@@ -1219,6 +1267,18 @@ void testIsAssignable_ClassWithParameterizedType3() {
         assertFalse(TypeUtils.isAssignable(from3, to3), "Class<TestImpl2> should not be assignable to Class<? extends TestIF<Number>>");
     }
 
+    @Test
+    void testIsAssignable_ClassWithParameterizedWildcardBound() {
+        final Type to = TypeUtils.parameterize(Class.class,
+                TypeUtils.wildcardType().withUpperBounds(TypeUtils.parameterize(Collection.class, TypeUtils.wildcardType().build())).build());
+        assertTrue(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, TypeUtils.parameterize(List.class, String.class)), to),
+                "Class<List<String>> should be assignable to Class<? extends Collection<?>>");
+        assertFalse(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, TypeUtils.parameterize(List.class, String.class)),
+                TypeUtils.parameterize(Class.class, TypeUtils.wildcardType()
+                        .withUpperBounds(TypeUtils.parameterize(Collection.class, Integer.class)).build())),
+                "Class<List<String>> should not be assignable to Class<? extends Collection<Integer>>");
+    }
+
     @Test
     void testIsAssignable_ClassWithUnboundedWildcard() {
         final Type to = TypeUtils.parameterize(Class.class, TypeUtils.wildcardType().build());
@@ -1235,13 +1295,6 @@ void testIsAssignable_ClassWithUnboundedWildcard() {
                 "Class<?> should not be assignable to Class<Integer>");
     }
 
-    @Test
-    void testIsAssignable_ClassWithBoundedWildcardObject() {
-        final Type to = TypeUtils.parameterize(Class.class, TypeUtils.wildcardType().withUpperBounds(Object.class).build());
-        assertTrue(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, String.class), to),
-                "Class<String> should be assignable to Class<? extends Object>");
-    }
-
     @Test
     void testIsAssignable_ClassWithUpperBoundedWildcard() {
         final Type to = TypeUtils.parameterize(Class.class, TypeUtils.wildcardType().withUpperBounds(Number.class).build());
@@ -1253,49 +1306,6 @@ void testIsAssignable_ClassWithUpperBoundedWildcard() {
                 "Class<String> should not be assignable to Class<? extends Number>");
     }
 
-    @Test
-    void testIsAssignable_ClassWithLowerBoundedWildcard() {
-        final Type to = TypeUtils.parameterize(Class.class, TypeUtils.wildcardType().withLowerBounds(Integer.class).build());
-        assertTrue(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, Number.class), to),
-                "Class<Number> should be assignable to Class<? super Integer>");
-        assertTrue(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, TypeUtils.wildcardType().withLowerBounds(Number.class).build()), to),
-                "Class<? super Number> should be assignable to Class<? super Integer>");
-        assertFalse(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, String.class), to),
-                "Class<String> should not be assignable to Class<? super Integer>");
-        assertTrue(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, List.class),
-                TypeUtils.parameterize(Class.class, TypeUtils.wildcardType().withLowerBounds(TypeUtils.parameterize(List.class, String.class)).build())),
-                "Class<List> should be assignable to Class<? super List<String>>");
-    }
-
-    @Test
-    void testIsAssignable_ClassWithParameterizedWildcardBound() {
-        final Type to = TypeUtils.parameterize(Class.class,
-                TypeUtils.wildcardType().withUpperBounds(TypeUtils.parameterize(Collection.class, TypeUtils.wildcardType().build())).build());
-        assertTrue(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, TypeUtils.parameterize(List.class, String.class)), to),
-                "Class<List<String>> should be assignable to Class<? extends Collection<?>>");
-        assertFalse(TypeUtils.isAssignable(TypeUtils.parameterize(Class.class, TypeUtils.parameterize(List.class, String.class)),
-                TypeUtils.parameterize(Class.class, TypeUtils.wildcardType()
-                        .withUpperBounds(TypeUtils.parameterize(Collection.class, Integer.class)).build())),
-                "Class<List<String>> should not be assignable to Class<? extends Collection<Integer>>");
-    }
-
-    @ParameterizedTest(name = "{0} -> {1}: {2}")
-    @MethodSource
-    void testIsAssignable_ClassWildcardBounds(final String fromField, final String toField, final boolean expected) throws NoSuchFieldException {
-        final Type from = ClassWildcardTypes.class.getDeclaredField(fromField).getGenericType();
-        final Type to = ClassWildcardTypes.class.getDeclaredField(toField).getGenericType();
-        assertEquals(expected, TypeUtils.isAssignable(from, to), () -> from + " -> " + to);
-    }
-
-    @Test
-    void testIsAssignable_UnrelatedTypeVariables() {
-        final TypeVariable<?>[] variables = ClassWildcardTypes.class.getTypeParameters();
-        assertFalse(TypeUtils.isAssignable(variables[0], variables[1]));
-        assertFalse(TypeUtils.isAssignable(variables[1], variables[0]));
-        assertFalse(TypeUtils.isAssignable(variables[1], variables[3]));
-        assertTrue(TypeUtils.isAssignable(variables[3], variables[1]));
-    }
-
     @Test
     public void testIsAssignable_HiddenMapLogic() {
         class Container<T> {
@@ -1315,6 +1325,15 @@ class Container<T> {
         assertFalse(result);
     }
 
+    @Test
+    void testIsAssignable_UnrelatedTypeVariables() {
+        final TypeVariable<?>[] variables = ClassWildcardTypes.class.getTypeParameters();
+        assertFalse(TypeUtils.isAssignable(variables[0], variables[1]));
+        assertFalse(TypeUtils.isAssignable(variables[1], variables[0]));
+        assertFalse(TypeUtils.isAssignable(variables[1], variables[3]));
+        assertTrue(TypeUtils.isAssignable(variables[3], variables[1]));
+    }
+
     @Test
     void testIsAssignableClasses() {
         assertTrue(TypeUtils.isAssignable(char.class, double.class));
@@ -1987,6 +2006,19 @@ void testUnrollVariablesCyclicOwnerChainFromEitherType() {
         assertSame(b, TypeUtils.unrollVariables(mapping, b));
     }
 
+    @Test
+    void testUnrollVariablesGenericArray() {
+        final TypeVariable<?> variable = ArrayValues.class.getTypeParameters()[0];
+        final GenericArrayType array = TypeUtils.genericArrayType(variable);
+        assertSame(array, TypeUtils.unrollVariables(Collections.emptyMap(), array));
+        assertSame(array, TypeUtils.unrollVariables(Collections.singletonMap(variable, variable), array));
+        assertEquals(String[].class, TypeUtils.unrollVariables(Collections.singletonMap(variable, String.class), array));
+        final Type list = TypeUtils.parameterize(List.class, String.class);
+        assertEquals(TypeUtils.genericArrayType(list), TypeUtils.unrollVariables(Collections.singletonMap(variable, list), array));
+        assertEquals(String[][].class,
+                TypeUtils.unrollVariables(Collections.singletonMap(variable, String.class), TypeUtils.genericArrayType(array)));
+    }
+
     @Test
     void testUnrollVariablesMultiLevelOwnerType() throws NoSuchFieldException {
         final TypeVariable<?> t = Outer.class.getTypeParameters()[0];