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.
ValueMapper, or, to be precise, thePObjectToDataObjectconverter, analyzes constructors of classes to figure out how to map Pkl structures to them. This allows one to useValueMapperto 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:
and this Pkl module:
if the
xproperty of this Pkl module is decoded intoFoo1, everything works. If it is decoded intoFoo2, however, you get an extremely confusing error:Simple reproducer code:
The reason, as I've discovered, is in this call:
pkl/pkl-config-java/src/main/java/org/pkl/config/java/mapper/Reflection.java
Lines 142 to 143 in 82afa8b
GenericTypeReflectorcomes from the geantyref library, and internally it does this:exehere is ajava.lang.reflect.Constructorinstance.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; onlygetGenericParameterTypes()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 theExecutableimplementation, there is this piece of logic:which forces synthetic and implicit parameters to be returned as erased types.
The problem is, in the original
Foo2record, 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.Foo1does 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.