Proving Java generic contracts with reflection after erasure

Java generics give compile-time protection, but the JVM does not preserve every generic detail in the same way. Type erasure removes type arguments from ordinary class objects, while reflection retains selected declaration metadata such as type-variable names, bounds, and parameterised supertypes. A useful reflection test must understand that split: it can verify the generic contract exposed by an API, but it cannot recover the concrete type argument of an arbitrary object created at runtime.

Reflection tests are most valuable when a library, persistence layer, or serialisation framework promises to interpret generic declarations correctly. They can catch accidental changes to signatures, reveal incorrect type parameters, and supply evidence that bridge methods or generic supertypes behave as expected. They complement, rather than replace, ordinary compile-time checks and runtime data tests.

What type erasure removes at runtime

At compile time, Box<String> and Box<Integer> are distinct types to the compiler. After compilation, both use the erased Box class, and new Box("x").getClass() cannot tell you that the value was a string. A class-level cast also cannot restore that information; the cast exists only to let the compiler insert a check where required.

Erasure follows defined rules. An unbounded variable T becomes Object, a variable bounded by T extends Number becomes Number, and a parameter such as List<T> becomes List. When several bounds are present, the first class bound determines the erased type, although reflection may report all declared bounds.

The crucial boundary lies between a declaration and an instance. Field.getGenericType() can expose that a field was declared as Account, but no reflective API can reveal that a particular List object contains strings rather than integers. Type arguments inferred for a method call or constructor invocation are likewise unavailable unless the program captures them separately.

What reflection still exposes

The reflective type model provides several targeted views. Class.getTypeParameters() describes variables declared by a class or interface; getGenericSuperclass() and getGenericInterfaces() inspect its parent types; and fields and methods expose their generic types through getGenericType(), getGenericReturnType(), and getGenericParameterTypes().

These methods return different Type implementations. ParameterizedType represents a construct such as Page<Account>, TypeVariable represents T, GenericArrayType represents T[], and WildcardType represents ? extends Account. Their nested arguments and wildcard bounds must be traversed when a signature contains several layers.

Reflection also has deliberate blind spots. Raw declarations omit generic metadata, local variables usually leave no runtime trace, and synthetic or bridge methods can appear beside the methods written by a developer. Reflection can describe what the class file declares, but it does not turn erased runtime values back into source-level types.

Building a stable reflection test fixture

Start with a tiny class whose expected metadata is obvious. JUnit 5 and AssertJ make the assertions explicit without introducing a framework dependency into the class under test.

public final class Box<T> {
    private final T value;

    public Box(T value) {
        this.value = value;
    }

    public T value() {
        return value;
    }
}

The test can assert the variable name and bound directly. It should inspect Box.class.getTypeParameters() rather than trying to write Box<String>.class, because Java provides no runtime syntax for a parameterised class literal.

TypeVariable<?> variable = Box.class.getTypeParameters()[0];

assertThat(variable.getName()).isEqualTo("T");
assertThat(variable.getBounds()).hasSize(1);
assertThat(variable.getBounds()[0].getTypeName())
        .isEqualTo("java.lang.Object");

Keep the fixture narrower than the production model. If the real concern is a field named value, also assert Box.class.getDeclaredField("value").getGenericType(); if it is a method, inspect its generic return type. Precise assertions produce useful failures and prevent a broad test from passing for the wrong reason.

Testing generic inheritance and interfaces

A parent relationship may be parameterised even when the child class is not. Consider a repository over accounts and a concrete implementation that fixes the argument to Account.

interface Repository<T> {}
final class Account {}
final class AccountRepository extends Repository<Account> {}

Here, getGenericSuperclass() returns a ParameterizedType. Its raw type is Repository, while its first actual type argument is Account. This is different from getSuperclass(), which returns only the raw Repository class.

ParameterizedType parent =
        (ParameterizedType) AccountRepository.class.getGenericSuperclass();

assertThat(parent.getRawType()).isEqualTo(Repository.class);
assertThat(parent.getActualTypeArguments()[0]).isEqualTo(Account.class);

The same checks apply to getGenericInterfaces(), but real hierarchies need more care. If Leaf extends Middle<String> and Middle<T> extends Base<T>, the leaf declaration mentions String, while the base still mentions T; resolving that variable requires walking the hierarchy. For projects in Sydney and Melbourne integrating Australian Open Banking or Consumer Data Right (CDR) data, such resolution tests help ensure a Java client preserves the intended schema across adapter layers.

Inspecting fields, methods, and nested types

A field can carry a richer signature than its erased class suggests. For Page<Account>, getGenericType() returns a ParameterizedType whose argument is Account; for List<? extends Account>, it contains a WildcardType; for T[], it returns a GenericArrayType. Each node should be inspected explicitly instead of comparing only field.getType().

Methods need two separate checks. The generic return type and parameter types describe the source contract, while getDeclaredMethods() may also expose compiler-generated bridges for overridden generic methods. Filter with isBridge() and isSynthetic() when the test targets handwritten methods, but include them in a separate test when bridge behaviour is the subject.

A generic method such as <T> T decode(String json) still exposes T as a TypeVariable after compilation. Calling it with a string may infer a particular type inside the method, yet reflection cannot report that inferred choice from the returned object. This limit is a reason to test generic decoding through observable output, not to expect a reflective answer that the JVM never retained.

Connecting metadata to API behaviour

A declared signature can be correct while the API still returns the wrong JSON shape, applies the wrong validation, or maps fields incorrectly. Reflection tests should therefore verify the type contract, while separate behavioural tests use representative payloads to verify what consumers actually receive. For example, a test can confirm that ApiResponse<Transfer> is declared as expected and then round-trip a transfer object through the serialiser.

Groovy teams can apply the same separation in specifications. The data-driven Spock patterns suit tables of expected signatures and outcomes, while JUnit remains a natural fit for services written entirely in Java. The useful result is a readable contract, not a preference between languages.

Fixtures used by Australian banking, payment, superannuation, and retail systems may contain personal information. For organisations subject to the Privacy Act 1988, the Australian Privacy Principles shape expectations around data minimisation and security. Synthetic account numbers, masked identifiers, and assertions that avoid printing full payloads make failures safer to share across an engineering team and its support partners.

Making the suite practical in Australia

For an Australian audience, geography and working patterns affect how a test suite is operated. Java services are prominent in Sydney and Melbourne enterprise and fintech markets, while delivery teams may be distributed across Brisbane, Perth, and Adelaide. A vague failure that only says “type mismatch” may be read hours later during a handover or after-hours shift, so include the member name, declaring class, and complete expected type in the diagnostic.

Continuous integration should also make reflection checks deterministic. Pin the Java toolchain used by Gradle or Maven, compare Type structures rather than their display strings, and keep classpath or module-order assumptions out of assertions. Those practices suit Australian organisations operating both domestic and offshore support arrangements, where the same test result must remain reproducible for every participant.

The durable distinction is simple: reflection tests prove the generic structure still published by a declaration, while behavioural tests prove that the running program uses that structure correctly. Reflection is the right tool for the first responsibility, not an attempt to resurrect information erased from the runtime; remembering that boundary keeps both kinds of test focused and trustworthy.