Skip to content

ValueMapper/PObjectToDataObject converter cannot deserialize to records with generic components AND with custom constructors #1485

@netvl

Description

@netvl

ValueMapper, or, to be precise, the PObjectToDataObject converter, analyzes constructors of classes to figure out how to map Pkl structures to them. This allows one to use ValueMapper to decode Pkl structures to any class, as long as its constructor matches the decoded Pkl value. It works even if you don't use codegen, and if you, say, define a record with appropriate components, it will "just work" (in most cases).

Now, consider these two records:

record Foo1(List<String> input) {}

record Foo2(List<String> input) {
    public Foo2 {
        if (input.isEmpty()) throw new IllegalArgumentException("input is empty");
    }
}

and this Pkl module:

class Foo {
  input: Listing<String>
}

x: Foo = new { input { "abc" } }

if the x property of this Pkl module is decoded into Foo1, everything works. If it is decoded into Foo2, however, you get an extremely confusing error:

Target type `interface java.util.List` is missing type arguments.
java.lang.IllegalArgumentException: Target type `interface java.util.List` is missing type arguments.
	at org.pkl.config.java.mapper.ValueMapperImpl.getConverter(ValueMapperImpl.java:83)
	at org.pkl.config.java.mapper.PObjectToDataObject$ConverterImpl.convert(PObjectToDataObject.java:204)
	at org.pkl.config.java.mapper.PObjectToDataObject$ConverterImpl.convert(PObjectToDataObject.java:154)
	at org.pkl.config.java.mapper.ValueMapperImpl.map(ValueMapperImpl.java:57)
	at org.pkl.config.java.AbstractConfig.as(AbstractConfig.java:57)
	at org.pkl.config.java.AbstractConfig.as(AbstractConfig.java:52)

Simple reproducer code:

package com.example;

import org.pkl.config.java.ConfigEvaluator;
import org.pkl.core.ModuleSource;

import java.util.List;

public class DecodingIssueTest {
    public record Foo1(List<String> input) {}

    public record Foo2(List<String> input) {
        public Foo2 {
            if (input.isEmpty()) throw new IllegalArgumentException("input is empty");
        }
    }

    public static void main(String[] args) {
        final var moduleText = """
            class Foo {
              input: Listing<String>
            }
            
            x: Foo = new { input { "abc" } }
            """;

        try (final var evaluator = ConfigEvaluator.preconfigured()) {
            // This works fine, you can see `foo1` being printed.
            final var foo1 = evaluator.evaluate(ModuleSource.text(moduleText)).get("x").as(Foo1.class);
            System.out.println(foo1);
            // This throws.
            final var foo2 = evaluator.evaluate(ModuleSource.text(moduleText)).get("x").as(Foo2.class);
            System.out.println(foo2);
        }
    }
}

The reason, as I've discovered, is in this call:

GenericTypeReflector.getExactParameterTypes(
m, GenericTypeReflector.annotate(declaringType)))

GenericTypeReflector comes from the geantyref library, and internally it does this:

AnnotatedType[] parameterTypes = exe.getAnnotatedParameterTypes();

exe here is a java.lang.reflect.Constructor instance. Executable.getAnnotatedParameterTypes() returns an array of parameter types which may or may not be parameterized; they might as well be erased. JDK does not guarantee that they will not be erased (see discussion here; only getGenericParameterTypes() explicitly guarantees to return generic information). And in fact, on modern JDKs, up to and including JDK 25, this is exactly what may happen: internally, in the Executable implementation, there is this piece of logic:

                    if (param.isSynthetic() || param.isImplicit()) {
                        // If we hit a synthetic or mandated parameter,
                        // use the non generic parameter info.
                        out[i] = nonGenericParamTypes[i];
                    } else {
                        // Otherwise, use the generic parameter info.
                        out[i] = genericParamTypes[fromidx];
                        fromidx++;
                    }

which forces synthetic and implicit parameters to be returned as erased types.

The problem is, in the original Foo2 record, because of the presence of the custom constructor, all its parameters are annotated as implicit, and therefore this method returns erased information about constructor parameters. This, in turn, breaks Pkl's converter resolution, since it relies on generic parameter info to find the appropriate converter.
Foo1 does not have a custom constructor, so its parameters are not marked as implicit, and thus everything works fine.

Ideally, the geantyref library should probably take this into account and use getGenericParameterTypes(), but maybe there is a way to work around it in Pkl directly.

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