-
-
Notifications
You must be signed in to change notification settings - Fork 45
Description
@cowtowncoder I'm in the middle of creating an abstract database interface that involves transparent storage of objects (initially Java record) with full type conversions. For a corner of the conversion I'm using Jackson ObjectMapper, although I'm not sure about what I will use long-term. In any case I had a chance to dig into my old PLOOP code (described in #37) and how it relates to ResolvedType (see e.g. our discussion in #69). There are several tensions lurking in those discussions, as well as the proposal I made in #76 that GenericType<T> should not really be a java.lang.reflect.Type.
For this current work I just now had a long and in-depth conversation with one of the top LLMs, refreshing my memory about these issues and understanding what it would look like to bring PLOOP up to date and carry it forward using ClassMate. The benefit here is that in our discussion we started with actual, practical, working code that we had just implemented in this database framework. We looked at how our new implementation could be generalized by bringing in PLOOP+ClassMate. We came upon some important insights, which I believe form a synthesis of the tensions I was feeling in those other tickets, especially in light of the new PR #103 which basically tries to reverse a ResolvedType back into a Type. Below is a summary (produced by the LLM) of those insights. I leave a few additional comments at the end.
ClassMate ResolvedType Design Insights
Background
This builds on discussions from #37 (PLOOP properties layer, 2017), #69 (ResolvedType → JavaType conversion), and the draft PR #103 (ResolvedTypeMapper). These issues circle around the same fundamental design tension in ClassMate's type abstraction.
The Core Problem
ResolvedType currently implements java.lang.reflect.Type, which appears to offer seamless interoperability. However, Type has subtype interfaces (ParameterizedType, GenericArrayType, TypeVariable, WildcardType) that code routinely inspects:
Type type = ...;
if (type instanceof ParameterizedType pt) {
Type[] args = pt.getActualTypeArguments();
// process generic arguments
}ResolvedType doesn't implement these subtypes. Code that receives a ResolvedType through a Type reference and checks instanceof ParameterizedType gets false—silently losing all generic information. This affects Jackson's TypeFactory, Hibernate Validator, and any API that inspects Type structure.
PR #103 addresses this by creating a ResolvedTypeMapper that synthesizes proper ParameterizedType and GenericArrayType implementations from ResolvedType data. This works, but it's an external workaround for what should be built-in behavior.
ResolvedType as a Type Replacement
The insight from working with these APIs is that ResolvedType should be understood as a replacement for Type in application code, not an implementation of it.
Within an application, you'd use ResolvedType everywhere—passing it through property descriptors, converters, serializers. You'd only touch Type at boundaries:
- Input: Converting from
Field.getGenericType(),RecordComponent.getGenericType(), etc. - Output: Converting to
Typewhen calling external APIs (Jackson, Hibernate, etc.)
This positions ResolvedType as a richer abstraction that shields application code from Type's design limitations while remaining interoperable.
Why ResolvedType Should Be Generic
Java's reflection types aren't generic—ParameterizedType.getRawType() returns Type, not Type<T>. This forces runtime casts and loses compile-time safety:
Type listType = field.getGenericType(); // List<String>
Class<?> raw = getRawType(listType);
Object result = raw.cast(someValue); // no compile-time safetyA generic ResolvedType<T> would preserve type safety through the chain:
ResolvedType<List<String>> listType = resolver.resolve(field);
Class<List<String>> raw = listType.erasedClass();
List<String> result = raw.cast(someValue); // type-safeThis matters for frameworks passing type information through multiple layers.
Why ResolvedType Should Be an Interface
ClassMate's value is resolving type variables through inheritance hierarchies. But not all contexts need that resolution:
record User(String id, List<String> tags) {}For records, RecordComponent.getGenericType() returns fully-specified types. ClassMate resolution adds overhead without benefit.
If ResolvedType were an interface, there could be multiple implementations:
- Simple wrapper: Stores the original
Typedirectly;toType()returns it unchanged - Resolution result: ClassMate-backed;
toType()synthesizes aTypefrom resolved data
Both satisfy the same interface. Consumers don't know or care which implementation they receive.
The toType() Bridge
Rather than implementing Type (which fails instanceof checks), ResolvedType should provide an explicit toType() method that returns a proper Type hierarchy:
- For simple wrappers: return the stored original
Type - For resolution results: synthesize
ParameterizedTypeImpl,GenericArrayTypeImpl, etc. (as PR Draft: Issue #69 | Add ResolvedTypeMapper to convert ResolvedType into Type #103 does)
The synthesized types implement the correct interfaces, so instanceof ParameterizedType works as expected. External APIs receive types they can inspect normally.
This is essentially what PR #103 provides externally. Building it into ResolvedType would make it a first-class capability.
Proposed Design Sketch
public interface ResolvedType<T> {
Class<T> erasedClass();
List<ResolvedType<?>> typeArguments();
Optional<ResolvedType<?>> typeArgument(int index);
boolean isParameterized();
boolean isArray();
Optional<ResolvedType<?>> arrayComponentType();
Type toType(); // original or synthesized
// Factory methods
static <T> ResolvedType<T> of(Class<T> clazz) { ... }
static ResolvedType<?> of(Type type) { ... } // simple wrapper, stores original
static ResolvedType<?> resolve(Type type, Class<?> context) { ... } // full resolution
}Key design points:
- Interface, not abstract class—allows implementation flexibility
- Generic
<T>parameter—preserves compile-time type safety - Does not implement
java.lang.reflect.Type—avoidsinstanceoffailures toType()method—explicit bridge for external API interoperability- Multiple factory methods—simple wrapping vs. full resolution
Summary
ResolvedType currently occupies an awkward middle ground: implementing Type suggests compatibility, but the subtype contracts aren't satisfied. PR #103 demonstrates that users need proper Type representations for external APIs, but the current design forces external workarounds.
The proposed redesign clarifies ResolvedType's role: a richer, type-safe replacement for Type within application code, with an explicit toType() bridge for interoperability. Making it an interface enables both lightweight wrappers (preserving original types) and full resolution results (synthesizing types), unified under a common abstraction.
This raises lots of questions which we can discuss in the comments. e.g. Is ResolvedType the best name for this new, redesigned thing? Are you interested in going in this direction for a new major ClassMate version?
For the short term in my framework I will create this general, improved ResolvedType interface (probably with a different name), with factories that produce it. The early iterations may have ClassMate and the current ResolvedType serving as backing implementations, depending on how much this helps go forward quickly. Longer term I would hope that this interface on our end could be replaced with a redesigned ClassMate ResolvedType, whatever its name; and/or integrated with PLOOP.
I would also be interested in discussing the possibility of working with you for a new ClassMate that might even have a separate property description facility mentioned in #37 and possibly even used eventually in a new version of Jackson. (With LLMs such large-scale thinking and refactoring suddenly becomes much more feasible, even producing better results than if humans were to painstakingly pore over the code—as long as innovative, design-oriented humans direct and guide the LLMs.)