Skip to content

Redesign ResolvedType to be a generic interface that does not implement Type #111

Open

Description

@garretwilson

@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 Type when 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 safety

A 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-safe

This 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 Type directly; toType() returns it unchanged
  • Resolution result: ClassMate-backed; toType() synthesizes a Type from 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:

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—avoids instanceof failures
  • 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.)

Activity

  1. cowtowncoder commented on Dec 23, 2025

    @cowtowncoder
    Member

    Quick notes...

    First of all, most of changes would require major version bump, or fork (which "proper" Java library major version bump often needs to be anyway, see Jackson).

    With that, there are sort of 2 tracks:

    1. What, if anything (... some toJDKType() conversions?) to target for 1.x
    2. If and how to pursue bigger version bump: both
      a. Practical (new coordinates? new repo? (i.e. fork)) and
      b. Functionality/design

    On 2 b, my initial gut reaction is

    • ResolvedType as a Type Replacement (not instance). Yes, indeed.
    • ResolvedType an interface... maybe, not feeling strongly here. Example wrt Record tho makes zero sense to me; Records require resolution same as POJOs (for List<String> fields etc)
    • ResolvedType generic: no, I don't think so. LLM may be misguided here; trying to use generic for use like this is more likely an anti-pattern in actual use -- code ends up fighting against generic typing. Esp. if trying to parameterize along java.lang.reflect.Type

    On 1, we should probably consider fate of #69 / #103.

    Idea of something like toJDKType() for ResolvedType sounds like reasonable path wrt 1.x, but maybe I forget something.

  2. cowtowncoder commented on Dec 23, 2025

    @cowtowncoder
    Member

    Oh and on whether to go with Big Redesign... I don't know. Right now my bandwidth is somewhat limited, to be honest.

  3. garretwilson commented on Dec 23, 2025

    @garretwilson
    Author

    Thanks for reading, @cowtowncoder . My objective was just to gather all my thoughts on this topic over the years, together with some deeper insights that I gained while using an LLM to help me remember the context of all this. Even if you do not decide to go in a large-scale refactoring, I thought you might be interested in this synthesis and proposal.

    To clarify, this insight was based upon a revisiting of ClassMate in relation to an actual implementation I'm working on, together with a discussion using an LLM as a soundboard. Although te LLM produced the summary of my thoughts, this is not a proposal the LLM dreamed up on its own.

    ResolvedType an interface... maybe, not feeling strongly here. Example wrt Record tho makes zero sense to me; Records require resolution same as POJOs (for List<String> fields etc)

    In trying to remember why ClassMate is useful in the context of the application I'm working on, I confess that I did rely on the LLM's assertion that ClassMate resolution is not needed for record. When I presented it with your reaction, it qualified what it had stated:

    ClassMate is needed for record component types if and only if the record itself is generic.

    That's it. That's the complete rule.

    Record Declaration Component Type getGenericType() Returns ClassMate Needed?
    record User(List<String> tags) List<String> ParameterizedType for List<String> No
    record Box<T>(T value) T TypeVariable for T Yes
    record Wrapper<T>(List<T> items) List<T> ParameterizedType with TypeVariable inside Yes

    What the ClassMate Author Likely Meant

    When he said "Records require resolution same as POJOs (for List<String> fields etc)", I believe he was thinking of case 3 above:

    record Wrapper<T>(List<T> items) {}

    If you have a Wrapper<String>, the component's declared type is List<T>. You need ClassMate to resolve that to List<String>. His parenthetical "(for List<String> fields etc)" referred to arriving at List<String> through resolution, not starting with it.

    Is that what you meant? And is the LLM's assertion that "ClassMate is needed for record component types if and only if the record itself is generic" correct? If not, let me know. I haven't dug deep into your ClassMate resolution code, so if there are other instances other than a generic record in which resolution is needed, I want to know!

    Nevertheless if we agree that most records (I don't know; I'm just guessing) aren't generic (and there are doubtless other cases in which the ResolvedType "factory" could tell that resolution were not needed), I still see value of the case in which a lightweight ResolvedType interface implementation would simply wrap around the original Type would be probably more efficient than carrying around all that ResolvedType carries around now, and without the need to reconstruct the Type in the new toType() method.

    ResolvedType generic: no, I don't think so. LLM may be misguided here; trying to use generic for use like this is more likely an anti-pattern in actual use -- code ends up fighting against generic typing. Esp. if trying to parameterize along java.lang.reflect.Type

    We can't blame this one on the LLM. 😄 This was something I had already added to PLOOP, and I mentioned that two years ago on #69:

    If only there were a ResolvedType.getType() or something that would give me back the original type—or some (reasonably short) algorithm to reconstruct it.

    If there is not, after I pull out my PLOOP library and dust it off, I may improve its TypeInfo<T> class to store, not only the ClassMate ResolvedType, but also the original Type that was used as input to TypeResolver. This will give me a convenient TypeInfo<T> type holder to pass around, providing me acess to ResolvedType but also giving me the original Type in case I need to use it with Jackson.

    This generic PLOOP TypeInfo<T> I referred to is something I wrote back in 2017:

    /**
     * A value class for representing a ClassMate {@link ResolvedType} along with its original generics-erased type.
     * <p>
     * This class is a lightweight wrapper for {@link ResolvedType} that regains the captured generics that are not available from
     * {@link ResolvedType#getErasedType()}.
     * </p>
     * @param <T> The type of value represented.
     * @author Garret Wilson
     * @see ResolvedType
     */
    public class TypeInfo<T> {
    
    	/** The shared singleton instance of the ClassMate type resolver. */
    	public static final TypeResolver TYPE_RESOLVER = new TypeResolver();
    
    	private final ResolvedType resolvedType;
    
    	/** @return The resolved type. */
    	public ResolvedType getResolvedType() {
    		return resolvedType;
    	}
    
    	/** @return The original type with runtime generics information erased. */
    	@SuppressWarnings("unchecked")
    	public Class<T> getErasedType() {
    		return (Class<T>)getResolvedType().getErasedType();
    	}
    
    …

    One thing I'm noticing now though (it was a long time ago) is that my type T seems to be in terms of the erased type. But I think that was still valuable in the actual usage. I'll have to see once I get deeper back into the code.

    You can see that all these ideas (a generic type T indicating the original type, the ability to produce/reconstruct the original Type, etc.) were things we had been discussing here and there in these tickets. The LLM conversation and summary just provided me an opportunity to unify my thoughts.

    I'm going to go ahead and implement something like a ResolvedType<T> (with a different name) in my database abstraction library and see how it goes. I'll design it with the goal of eventually bringing it back into PLOOP or a similar library. I'll let you know how it turns out. If you later want to collaborate on some Big Redesign of ClassMate, let me know.

    Happy Holidays!

  4. cowtowncoder commented on Dec 23, 2025

    @cowtowncoder
    Member

    Even if you do not decide to go in a large-scale refactoring, I thought you might be interested in this synthesis and proposal.

    Absolutely!

    On Records: LLM answer seems to assume ClassMate is only needed for resolving Type Variables, or for main type itself; or if Record itself is generic. None of this is needed: Record with field List<String> is much easier to handle with ClassMate (or similar abstraction) than java.lang.reflect.Type. So I really do not see much distinction b/w POJOs and Records.

    Or maybe we just disagree in usefulness/value of java.lang.reflect.Type type hierarchy? To me it's a pain to use, best to do without (except for erased Class<?> which is necessary) so no code ever should use Type for anything -- all access should be using ResolvedType.
    However, that leads to question of why ClassMate has no ResolvedRecordType -- and it's something that probably ought to be added, in v2 (since v1 is Java 8-based, still).

    But I am more puzzled by TypeInfo<T> -- just class parameterization of Class<T> itself I see limited (... or no) value for it. But particularly for ResolvedType. This based on usage by Jackson which perhaps differs from your usage of PLOOP?
    But basically, I've rarely (or never?) had a case where binding of T was available or useful for this case.
    It is always something to cast away, not use. When resolving POJOs, types being resolved are mostly for properties (Fields, Setter parameter, Getter result value), unrelated to containing type.
    So perhaps this is distinction between the "root type", outer-most type, where maybe type of thing being passed (Class<T>, TypeReference<T>) could be useful. And then secondary types (of Members) less so.

    Then again, to see pros and cons, it'd be necessary to try it out, and apply consistently.

    On possible ClassMate 2.x, it may seem silly, but for me big part really is knowing how to handle versioning.
    My gut feeling is that Jackson-style "re-package" is actually way to go, given that most ClassMate usage is (I think) via other libraries, frameworks, not direct use by applications. Which basically complicates compatibility aspects (due to need for binary compatibility, constraints (or not) of having deps to both 1.x and 2.x).
    But unlike Jackson, would probably not change com.fasterxml.classmate into tools.classmate but maybe rather com.fasterxml.classmate2 (more due to not needing to buy domain for Maven publishing etc).

  5. garretwilson commented on Dec 24, 2025

    @garretwilson
    Author

    On Records: LLM answer seems to assume ClassMate is only needed for resolving Type Variables, or for main type itself; or if Record itself is generic. None of this is needed: Record with field List<String> is much easier to handle with ClassMate (or similar abstraction) than java.lang.reflect.Type. So I really do not see much distinction b/w POJOs and Records.

    I think we may be talking past either other here. In further conversation with the LLM, it's starting to seem to me that there are three aspects to ResolvedType:

    • Recursive resolution of all TypeVariable<D> with ParameterizedType (and of course support of other Type implementations). The LLM really brought it home to me that this is the core "resolution" of ResolvedType. (If that's not correct, let me know.)
    • A general interface (a façade really) for working with types. This is I believe what you mean above where you say it's easier to use ClassMate than the Java Type hierarchy.
    • A means (a "bridge") for reconstructing some original Type—not because we enjoy working with Type, but because it's needed for interoperability with other APIs. (This is PR Draft: Issue #69 | Add ResolvedTypeMapper to convert ResolvedType into Type #103 .)

    So yes we would like to work with some reimagined ResolvedType everywhere instead of the java.lang.reflect.Type hierarchy. My point is merely that if ResolvedType were an interface, in cases in which all there are no TypeVariable<D> and the original Type was already fully resolved, the factory would simply provide a lightweight wrapper around the original Type. Yes an application would work with ResolvedType instead of Type, but internally this implementation wouldn't have to carry all the baggage that ResolvedType does now. For example your current ResolvedType.getTypeParameters() would delegate directly to …

    💡(light bulb moment)

    Oh, I see. ResolvedType.getTypeParameters() returns List<ResolvedType>. You're saying that you see a value in ResolvedType exposing the entire hierarchy as a parallel tree of ResolvedType, not just a full resolution of the Type tree by replacing all TypeVariable<D> with ParameterizedType. So even this lightweight Type wrapper (implementing a ResolvedType interface) would still return List<Type> if it simply delegated to e.g. ParameterizedType.getActualTypeArguments().

    But wait, would a caller always want to walk the entire ResolvedType tree? If the Type is already resolved, then when it gets turned into a ResolvedType is an implementation detail. So for a Type tree that was already resolved (which is quite often I would assume!), the lightweight implementation of ResolvedType.getTypeParameters() could still return List<ResolvedType>—it could simply in turn wrap ParameterizedType.getActualTypeArguments() in ResolvedType.ofAlreadyResolvedType(Type) for example. It could do this each time (like Optional.of()) or lazily cache them. Something like Stream.of(ParameterizedType.getActualTypeArguments()).map(ResolvedType::ofAlreadyResolvedType).toList() comes to mind.

    Actually I see that the LLM was even ahead of me on this point! Literally minutes before you responded above, it had mapped out what this interface would look like (calling it TypeInfo<>), and here is what it showed for getting type arguments:

        /// Returns the resolved type arguments, if any.
        ///
        /// For `List<String>`, returns `[TypeInfo<String>]`.
        /// For non-parameterized types like `String`, returns an empty list.
        ///
        /// @return Immutable list of type arguments; empty for non-parameterized types.
        List<TypeInfo<?>> typeArguments();

    So yes, we are all considering a new ResolvedType to be a full replacement for Type with a better interface, and for the whole tree (the LLM "understood" that part before I did).

    In conclusion I think I finally see why you were emphasizing that ResolvedType would always be useful to fully replace Type for the full hierarchy; that was something I overlooked before. And that's a good point, but I don't think it negates the benefit of having ResolvedType be an interface, and in some ways actually supports it!

    Thanks for pointing out that subtlety. I'm literally right now in the middle of implementing this re-envisioned ResolvedType as an interface, so I'll see how it turns out.

    On possible ClassMate 2.x, it may seem silly, but for me big part really is knowing how to handle versioning.

    My proposal was a no-holds-barred brainstorming look at what would be possible with a redesign, so if you ever go in that direction, I would expect it to be a brand new ClassMate major version in the very least.

  6. cowtowncoder commented on Dec 24, 2025

    @cowtowncoder
    Member

    In conclusion I think I finally see why you were emphasizing that ResolvedType would always be useful to fully replace Type for the full hierarchy; that was something I overlooked before. And that's a good point, but I don't think it negates the benefit of having ResolvedType be an interface, and in some ways actually supports it!

    Ah. This does explain why it did feel like we were talking past each other!

    And just to make sure: I am not really against ResolvedType being interface -- esp. when there's clear need.

    Also 100% agree with JDK Type convertibility being valuable for inter-operability.
    Slightly skeptical of feasibility of sort of hybrid/lazy resolution, mostly due to likely complexity of making things work on-demand, but that's also not something to immediately rule out/rule in.

  7. garretwilson commented on Dec 24, 2025

    @garretwilson
    Author

    Literally seconds before your last response, my LLM put out the first version (after days of discussing and planning) of this re-envisioned ResolvedType. I'm getting ready to take a break for the day, and I haven't fully reviewed everything (especially the part about wildcards), but it's looking beautiful and I want to show it to you to illustrate the elegance of what this ticket proposes, in particular the interface aspect.

    Here an extract from TypeInfo<T> (which would be the new ResolvedType I'm proposing):

    (Edit: Actually this approach is flawed, but the concept is sound. I've edited out some of the information in this comment below, keeping some for the record but striking it out because it wasn't quite correct. I'll provide an update in my follow-up comment.)

      /// Internal factory implementation.
      /// @param type The type to wrap.
      /// @param contextClass The context class for resolution, or `null` if not provided.
      /// @return Type info for the type.
      private static TypeInfo<?> createTypeInfo(final Type type, final @Nullable Class<?> contextClass) {
        requireNonNull(type);
        if (type instanceof Class<?> clazz) {
          @SuppressWarnings("unchecked")
          final TypeInfo<?> result = new SimpleTypeInfo<>((Class<Object>) clazz);
          return result;
        }
        if (type instanceof ParameterizedType parameterized) {
          return new ParameterizedTypeInfo<>(parameterized);
        }
        if (type instanceof GenericArrayType genericArray) {
          throw new IllegalArgumentException("GenericArrayType not yet supported: `%s`.".formatted(type.getTypeName()));
        }
        if (type instanceof TypeVariable<?>) {
          // ClassMate integration point: use contextClass to resolve
          throw new UnsupportedOperationException(
              "Unresolved type variable `%s`; ClassMate resolution not yet implemented.".formatted(type.getTypeName()));
        }
        if (type instanceof WildcardType wildcard) {
          // Resolve wildcards to their upper bound (usually Object for `?`)
          final Type[] upperBounds = wildcard.getUpperBounds();
          if (upperBounds.length > 0) {
            return createTypeInfo(upperBounds[0], contextClass);
          }
          return new SimpleTypeInfo<>(Object.class);
        }
        throw new IllegalArgumentException("Unsupported type: `%s`.".formatted(type.getTypeName()));
      }

    If we're just getting TypeInfo<> for a raw class, it returns new SimpleTypeInfo<>((Class<Object>) clazz).

    …

    Where it gets interesting is in a type like List<String>. It returns new ParameterizedTypeInfo<>(parameterized), the constructor of which just wraps lightweight wrappers around each generic type: … In a non-genericized record (which is going to be 100% of the data we support right now, and I imagine the most frequent thing you'll see in real life—as far as record goes anyway), the recursive TypeInfo::of simply winds up returning SimpleTypeInfo instances for the generic types! So we get a fully tree of TypeInfo replacement for Type, that works for probably the majority of data in the wild, without even touching ClassMate resolution!!

    But what happens if we have a record Wrapper<T>(List<T> items) {} and we try this with the List<T> items where we do need ClassMate resolution? (This is I think the objection you raised above the incorrect blanket statement that a record never needs its field types resolved.) The first createTypeInfo() level returns a ParameterizedTypeInfo as before, but when wrapping the parameterized type arguments the second level hits the TypeVariable and inside createTypeInfo() throws UnsupportedOperationException, as you see above. And that's all we need for now.

    The long-term solution is that ClassMate would adopt this overall approach, and inside createTypeInfo() it would use its TypeResolver logic to resolve the tree just as we are doing, resolving and wrapping the types in TypeInfo.

    But for the medium-term, when I need generic records or full generic object support, I'll simply implement a ResolvedTypeInfo that implements TypeInfo by wrapping the current ClassMate ResolvedType, providing lightweight wrappers around its hierarchy of ResolvedType instances. It won't be quite as efficient or lightweight as the long-term solution; that's why it's a medium term solution. But for the short term I don't even need genericized record support in the first place, so I don't even have to touch ClassMate at all! The beauty of all this is that this generalized interface allows me to plug in type resolution via ClassMate or even something else later and everything will just magically continue working behind the TypeInfo<> abstraction layer, without the caller knowing or caring.

  8. garretwilson commented on Dec 27, 2025

    @garretwilson
    Author

    The previous approach I outlined for TypeInfo<> with an integration point for ClassMate was simplistic and naive. I let the LLM do handwaving, but fortunately later I pressed it as to what that ClassMate integrate would entail, and it led to several days of deep research and design. Eventually I just went ahead and integrated ClassMate so I could be sure it could be done. My head is ready to explode and I came away with an even deeper appreciation for what @cowtowncoder has done with ClassMate. I now understand ClassMate probably three times better than I ever have—even more than I had back in 2017 when first working on PLOOP.

    Let's back up and talk about what ClassMate really is. (This is my current understanding; correct me where I'm wrong.)

    ClassMate "resolves" types. Here "resolved" essentially means _substituting every TypeVariable<> with something else. The "someting else" could be from:

    • A type bound in a generic "subject" type (using the language of propositional logic—the "main class" that has a property). This may involve navigating an inheritance hierarchy of type bindings.
    • Some generic wildcard bounds.
    • The upper bound of a TypeVariable<>

    Thus ClassMate is only useful when resolving Type is being resolved in relation to some concrete class that has type bindings.

    (That was where the simplistic approach above went wrong; there is no way to "patch in" ClassMate support for a single Type resolution; that's where things like MemberResolver come in.)

    So what value is a ClassMate ResolvedType then? From my current work, it provides three benefits:

    1. It ensures that type it represents is "resolved"; a ResolvedType will never contain a TypeVarable<>.
    2. It provides a rich, flattened, standardize API to replace the JDK Type hierarchy. (This is another reason ResolvedType should not implement Type.)
    3. It provides internal caching of types (an implementation detail about efficiency).

    With all this in mind, my TypeInfo framework (which represents a reimagined ResolvedType as I describe above) is complete. Its factory methods assume the underlying type is already "resolved". (Actually I think I'm going to refine this to automatically use TypeVariable<> and WildcardType bounds, like TypeResolver does.)

    public interface TypeInfo<T> {
    
      /// Returns the raw (erased) class.
      /// @return The raw class.
      Class<T> rawClass();
    
      /// Returns the resolved type arguments, if any.
      ///
      /// For explicitly parameterized types like `List<String>`, returns `[TypeInfo<String>]`.
      /// For raw generic types like `List.class` or `Box.class`, returns type arguments resolved
      /// to their declared upper bounds (e.g., `Object` for `<T>`, `Number` for `<T extends Number>`).
      /// For non-generic types like `String`, returns an empty list.
      ///
      /// @apiNote Implementations must ensure type arguments are always available for generic types,
      ///          even when only the raw class is provided. This prevents loss of type information
      ///          and ensures consistent behavior across the `TypeInfo` hierarchy.
      /// @return Immutable list of type arguments; empty only for non-generic types.
      List<TypeInfo<?>> typeArguments();
    
      /// Returns a JDK `Type` representing this type info.
      ///
      /// For parameterized types, the returned `Type` correctly implements `ParameterizedType`,
      /// ensuring `instanceof` checks work as expected.
      ///
      /// @return A `Type` representing this type info.
      Type toType();
    
      /// Indicates whether this represents a parameterized type.
      /// @return `true` if [#typeArguments()] is non-empty.
      default boolean isParameterized() {
        return !typeArguments().isEmpty();
      }
    
      //## Factory Methods
    
      /// Creates type info for a class.
      ///
      /// This factory method performs no resolution; the class must not contain unresolved type
      /// variables. For generic classes, type arguments are resolved to their declared upper bounds
      /// and available via [#typeArguments()].
      ///
      /// @implNote For generic classes, delegates to [ResolvedTypeInfo] to leverage ClassMate's
      ///           type resolution, which handles bound extraction. This depends on ClassMate
      ///           issue #53 being resolved (targeted for version 1.8.0).
      /// @param <T> The type.
      /// @param type The class.
      /// @return Type info for the class.
      static <T> TypeInfo<T> of(final Class<T> type) {
        requireNonNull(type);
        if(type.getTypeParameters().length > 0) { // generic class: resolve to get type arguments at bounds
          @SuppressWarnings("unchecked")
          final var result = (TypeInfo<T>)ResolvedTypeInfo.from(type);
          return result;
        }
        return new SimpleTypeInfo<>(type);
      }
    
      /// Creates type info from a [GenericType] super type token.
      ///
      /// This factory method performs no resolution; the type token captures already-resolved
      /// type information at compile time, guaranteeing that there are no unbound type variables.
      ///
      /// @apiNote Create an anonymous subclass to capture the type:
      ///          ```java
      ///          TypeInfo<List<String>> info = TypeInfo.of(new GenericType<List<String>>() {});
      ///          ```
      /// @param <T> The type.
      /// @param typeToken The type token capturing the generic type.
      /// @return Type info for the captured type.
      static <T> TypeInfo<T> of(final GenericType<T> typeToken) {
        requireNonNull(typeToken);
        @SuppressWarnings("unchecked")
        final var result = (TypeInfo<T>)of(Marshalling.typeTokenToType(typeToken));
        return result;
      }
    
      /// Creates type info from a reflection `Type`.
      ///
      /// This factory method performs no resolution; the type must already be fully resolved
      /// and contain no [TypeVariable]s. Use [#resolve(Type, TypeInfoResolver)] to resolve
      /// types that may contain type variables.
      ///
      /// @param type The type (must not contain unresolved type variables).
      /// @return Type info for the type.
      /// @throws IllegalArgumentException if the type contains unresolved type variables.
      static TypeInfo<?> of(final Type type) { //TODO add caching
        requireNonNull(type);
        if(type instanceof Class<?> clazz) {
          return of(clazz);
        }
        if(type instanceof ParameterizedType parameterized) {
          return new ParameterizedTypeInfo<>(parameterized);
        }
        if(type instanceof GenericArrayType genericArrayType) {
          return new GenericArrayTypeInfo<>(genericArrayType);
        }
        if(type instanceof TypeVariable<?> typeVariable) {
          throw new IllegalArgumentException("Cannot create TypeInfo for unresolved TypeVariable `%s`.".formatted(typeVariable.getName()));
        }
        if(type instanceof WildcardType wildcard) {
          return new WildcardTypeInfo<>(wildcard);
        }
        throw new IllegalArgumentException("Unsupported type: `%s`.".formatted(type.getTypeName()));
      }

    There is a SimpleTypeInfo which simply wraps the raw class, as you see above. We can return the original Type (the Class) directly. This is one of the benefits of TypeInfo (the reimagined ResolvedType) being an interface. Notice in particular that we detect if the type is a raw type, in which case we cheat and wrap around ClassMate's ResolvedType because the actual generic types may have a hierarchy. (This is where we discovered issue #53, still waiting to be released.) Again I think I will change the the TypeInfo.of() factories to simply use TypeVariable and WildcardType bounds.

    But it is the "resolution in the context of a subject type" part which is interesting, and the part I was missing before. I have abstracted that out into a TypeInfoResolver, and it is used like this:

      /// Resolves a type to a [TypeInfo], using the given resolver for type variable substitution.
      ///
      /// This method produces the most lightweight, efficient [TypeInfo] implementation possible:
      ///
      /// - **`Class<?>`**: Directly creates a [SimpleTypeInfo] without invoking the resolver.
      /// - **`TypeVariable<?>`**: Delegates to the resolver for substitution.
      /// - **`ParameterizedType`**: Delegates to the resolver, since the type arguments may contain
      ///   type variables that require substitution. Walking the type tree to check for type variables
      ///   would be more expensive than simply delegating.
      /// - **Other types**: Delegates to the resolver to handle appropriately.
      ///
      /// @implSpec The resolver is only invoked when the type _may_ require resolution. For concrete
      ///           types like `Class<?>`, this method short-circuits to [#of(Type)] to avoid the
      ///           overhead of resolution when none is needed.
      /// @param type The type to resolve.
      /// @param resolver The strategy for resolving type variables to their bound types.
      /// @return A [TypeInfo] representing the resolved type.
      static TypeInfo<?> resolve(final Type type, final TypeInfoResolver resolver) { //TODO add caching
        requireNonNull(type);
        requireNonNull(resolver);
        if(type instanceof Class<?>) { // Class has no type variables; use lightweight path.
          return of(type);
        }
        return resolver.resolve(type); // TypeVariable, ParameterizedType, etc. may need resolution.
      }

    And here is where it gets fun and efficient. I have one IDENTITY TypeInfoResolver that simply uses TypeInfo.of() if no resolution is needed. I have another TypeBindingsTypeInfoResolver, which looks up type bindings from a single ClassMate TypeBindings. But remember that ClassMate MemberResolver may have to navigate an entire hierarchy of type bindings if the subject type has inherited types. But in some cases we know that is not the case. In fact in some cases we know there is no resolution needed at all!

    So here is the record field converter code, in the new introspector framework that will eventually go back and be part of the updated PLOOP or a similar library:

          final TypeInfoResolver typeInfoResolver;
          if(isGeneric(rawClass)) {
            assert TypeBindingsTypeInfoResolver.isSufficientFor(typeInfo) : "Generic records don't need inheritance-aware type resolution.";
            typeInfoResolver = TypeBindingsTypeInfoResolver.from(typeInfo);
          } else {
            typeInfoResolver = TypeInfoResolver.IDENTITY;
          }
          this.properties = stream(rawClass.getRecordComponents()).<PropertyDescriptor>map(component -> new RecordPropertyDescriptor(component, typeInfoResolver))
              .toList();

    In other words, if we're dealing with records, and the record is not generic, there is no resolution needed at all because there can be nothing to resolve, so we use TypeInfoResolver.IDENTITY as the resolver, which just delegates to TypeInfo.of(). If the record is generic, we still don't have to use MemberResolver, because there can be no hierarchy of type bindings to navigate—there is only the TypBindings of the record! So we use a very simple TypeBindingsTypeInfoResolver that uses ClassMate either to resolve the type (using TypeResolver) to get its type bindings, or simply uses the existing ResolvedType if the TypInfo for the subject type already wraps a ResolvedType.

    Thus in the case of a general non-generic record (which is probably 95% of what we will be storing in the database), the TypeInfo instances we use are simple wrapper classes that never touch ClassMate. Even for generic records, we delegate to ClasssMate in the background but then just do a single-level TypeBindings lookup—no need for the whole MemberResolver facility because we knew ahead of time it would make no difference.

    The takeaway here is that this illustrates the benefit of having TypeInfo (the reimagined ResolvedType) be an interface, as it can simply return lightweight wrapper classes for things that don't need resolving in the first place.

  9. cowtowncoder commented on Dec 28, 2025

    @cowtowncoder
    Member

    One quick comment/confirmation on this:

    Let's back up and talk about what ClassMate really is. (This is my current understanding; correct me where I'm wrong.)

    ClassMate "resolves" types. Here "resolved" essentially means _substituting every TypeVariable<> with something else. The "something else" could be from:

    • A type bound in a generic "subject" type (using the language of propositional logic—the "main class" that has a property). This may involve navigating an inheritance hierarchy of type bindings.
    • Some generic wildcard bounds.
    • The upper bound of a TypeVariable<>

    Thus ClassMate is only useful when resolving Type is being resolved in relation to some concrete class that has type bindings.

    100% spot on. I learned contextual nature (Methods and Fields may depend on type variables of their declaring class and indirectly parent types from thereof -- and specifically type variables of base types' member only resolvable once sub-type's bindings are available) during Jackson development, but could only clean up the resolution in ClassMate

    Oh, and another note: Fix for #53 is now released as ClassMate 1.7.2.

  10. garretwilson commented on Dec 28, 2025

    @garretwilson
    Author

    One quick comment/confirmation on this: … 100% spot on.

    Thanks for the confirmation! As you know it's difficult to get a holistic sense of "what it all means" unless 1) there is a an immediate application to see how it works in practice, and 2) lots of poring over the nitty gritty. In my case, I'm working on a framework (the database abstraction) that uses this immediately, and the LLMs help answer questions by poring over many source files within seconds—something that would have taken me much longer before.

    I have now implemented TypeInfo<T> in my framework (with the intention of extracting it back out to the PLOOP library I started back in 2017), and it is now working as a proof of concept of this reconceptualization of ResolvedType I laid out in this ticket. However there were three nagging questions:

    • Should TypeInfo.of(Type) substitute TypeVariable<>s, or should it throw an exception if the type is not already "resolved"? (ClassMate TypeResolver.resolve() does the former.) The worry is that a user might inadvertently wrap a Type with TypeInfo, yet the type variables may not be fully "resolved" against some subject class. (Interestingly it turns out that in practice this hardly matters; in a framework, either a raw class is passed (usually derived from the write path of a generic class, e.g. new ArrayList<>() will result in the consumer receiving ArrayList.class) or a token type such as GenericType (usually on the read path, e.g. the user is saying they want a Foo<Bar> type). It seems rare we actually encounter just a bare Type floating around.)
    • Should a raw type such as NumberBox.class expose its declared types such as Number for declared NumberBox<T extends Number>? In issue Allow for subtype resolution with unknown generics #53, ClassMate made the decision of "yes". But in further analysis, I wondered whether this even means the same thing. It turns on what the purpose of TypeInfo/ResolvedType is (see below).
    • Should a toType() method return the original Type (e.g. containing TypeVariable<>s), or return a fully "resolved" Type equivalent? This is what we discussed in issue Convert GenericType or ResolvedType to JavaType #69, and at the time I'm not sure I even fully appreciated the distinction. PR Draft: Issue #69 | Add ResolvedTypeMapper to convert ResolvedType into Type #103 produces a fully-resolved Type equivalent of the ResolvedType, but that is partly out of necessity, because ResolvedType discards the original Type. In a reconceived TypeInfo/ResolvedType, the originally type could be saved and returned, e.g. for further resolution against a subject type. But is that useful? Why do we need a toType() to begin with?

    With these quandaries in mind, over the last 24 hours I've refined my conceptualization of TypeInfo in a way that I think addresses these concerns. Let's think of TypeInfo/ResolvedType as simply "usable type info". This doesn't imply it is fully resolved (i.e. in relation to a generic subject type) or where the type information came from. It just means you can use it without running into type variables. This is in line with how we use it—we pass other methods a TypeInfo for a property type, for example, and those methods use the type information to convert some value in a database back what what the user wants for example. It's up to a user to create the TypeInfo they want. In this view, TypeInfo.of() would simply provide the best type information available, whether it involves using upper bounds of TypeVariable<>, or doing something similar with WildcardTypes.

    In this view, sending back the declared type variables for a raw NumberBox.class (Number if NumberBox is declared as NumberBox<T extends Number>) is the correct thing to do, because TypeInfo is meant to be usable type info, and the method receiving it will want to know as much as possible about what the type arguments might be (or could be).

    If toType() is the bridge back to the JDK world, e.g. to supply to other APIs that don't understant TypeInfo, that means the Type produced should also represent the Type version of the TypeInfo—not the original Type unless the original Type already had all TypeVariable<>s substituted. But doesn't this mean TypeInfo.of() might loose value type information, such as what the type variables were, for further resolution against some subject class? It's starting to seem that there should be a separate method such as sourceType() that indicates the Type from which the TypeInfo was created. (For a TypeInfo backed by ClassMate ResolvedType we may not know the original type so in the short term we would have to use the toType() form, but long term `TypeInfo may not need ClassMate, or a future version of ClassMate may provide us that information.)

    Perhaps the conceptual hurdle was from originally thinking that TypeInfo/ResolvedType implied "resolved" in the sense of "fully resolved against some other subject type". In this refined thinking, ResolvedType means TypeVariableSubstitutedType without implying resolution against a subject type (although you want to do that of course to get the full picture). If TypeInfo seen as a more pragmatic "here is type information you can actually use", and it's up to the caller to make sure the TypeInfo is resolved in the best possible way, this refinement may provide a consistent, useful, and semantically appropriate API.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions