kotlinx.serialization icon indicating copy to clipboard operation
kotlinx.serialization copied to clipboard

Make default values available from descriptors

Open rnett opened this issue 4 months ago • 9 comments

What is your use-case and why do you need this feature?

When generating a schema from descriptors, or working with the descriptor tree in other ways (like my particular use-case: generating a UI form based on it), it would be nice to be able to access the default values of elements.

The biggest reason across all of these use-cases is documenting default values, e.g. in a schema or openAPI spec.

Describe the solution you'd like

A SerialDescriptor.getElementDefaultValue(index: Int): Any? or : (() -> Any?)? method that gets the default value if there is one or null if not, and a SerialDescriptor.elementHasDefaultValue(index: Int): Boolean method. The second one may be redundant with isElementOptional - I don't know whether the semantics are a 100% match.

rnett avatar Aug 18 '25 23:08 rnett

Related to my very old rejected request #2658

Chuckame avatar Sep 13 '25 12:09 Chuckame

The other request #2658 clarifies this is not actually possible as defaults don't actually have to be constant. They can depend on other values in the object (certainly with custom serializers). Defaults are purely the territory of the serializer/deserializer, and for generated serializers would be implemented equivalent to invoking the initialisation code (note that even emptyList() is a code invocation that could have changing behaviour).

As an aside in XmlUtil there is actually an XmlDefault annotation that works by actually storing a default serialized representation (that obviously is constant), but this is a format specific annotation.

pdvrieze avatar Sep 15 '25 09:09 pdvrieze

For sure I don't have all the needs on the planet, but I feel like the most common usage in serialized data models is have "pure", static defaults. Generally, the data model reflects the schema (xml, avro, proto and more) which are static.

This framework aims to be generic, but the field-defaults are, by nature, mostly static, not dependant to another field (as a schema is not able to have dynamic defaults), and prone to be taken into compatibility rules, so even less dependant to another field.

Chuckame avatar Sep 15 '25 11:09 Chuckame

@Chuckame The challenge is that based in pint that the only notion that even a generated serializer has of a default is as a code sequence that produces a result of a particular type. You are correct that in some cases the plugin could determine this to be a compile time constant (note that this excludes any function calls except some that are special cased as being able to be evaluated at compile time - intrinsics). This may even be for 90% of the cases.

ps. even emptyList() cannot be inlined as a "constant" would have shared identify (for two instances a and b: a ===b), where the default would (potentially) create a new instance (a !== b) - and yes I know it poor coding to rely on identity equality, but it happens, is very surprising, completely invisible (generated serializer descriptor), and hard to debug.

The problem is that formats must deal with every case, not just 90%. As such exposing default values to the descriptor is of limited use to the format, and not used to determine the actually deserialized value. This means the format is likely to completely ignore it. A consequence of it thus being "orphaned" is that there is a good chance of inconsistencies (especially for custom serializers).

Then there are two pieces of functionality that could benefit from having defaults:

  • Documentation: For this case, you want to perhaps have extended features in dokka and use a dokkalet to generate the documentation that includes the default value (or even code)
  • Schema generation: First of all, schemas have more depth than generally expressed in the serialization code. At the same time the same schema could be implemented in different ways - as such there is no 1:1 mapping in either direction. This means that schemas need to be edited, as much as the serialization code is. What should be possible is to check the code against a schema.

pdvrieze avatar Sep 15 '25 14:09 pdvrieze

Essentially duplicate of #1182 / #2658. I agree that such an API is useful for schema generation, but it will have to many limitations and be very prone to breaking. For example:

@Serializable data class A(val a: String = "foo") // A.serializer().descriptor.getDefaultValue(0) may return "foo" in theory

val FOO_DEFAULT = "foo"

@Serializable data class B(val b: String = FOO_DEFAULT) // B.serializer().descriptor.getDefaultValue(0) can't return "foo", not a compile-time constant

In addition, if we follow every reference in the default value expression and hold it in the descriptor, it will potentially open a whole new class of memory leaks. If we account for all caveats, the resulting API will be very limiting. Perhaps a better solution for a particular schema generator will be its own annotation like @SchemaDefault("xxx"), which would simply take a literal expression to embed directly in the resulting schema.

sandwwraith avatar Sep 15 '25 14:09 sandwwraith

In addition, if we follow every reference in the default value expression and hold it in the descriptor, it will potentially open a whole new class of memory leaks.

I don't see why the descriptor would need to hold so much? Presumably the generated serializer is aware of the defaults in some way and can produce them when required, so it could look something like this (very pseudo-code):


class GeneratedSerializer : KSerializer<Foo> {

  fun getDefaultA(): Int = ... // used in/extracted from the deserializationmethod
  fun getDefaultB(): String = ... // used in/extracted from the deserializationmethod

  val serialDescriptor = buildSerialDescriptor {
    element("a", Int.serializer()) { getDefaultA() }
    element("b", String.serializer()) { getDefaultB() }
  }

}

My assumption here is that they would be exposed as methods on the descriptor to get the default value, not as fields. This avoids a lot of the issues mentioned up-thread.

rnett avatar Sep 15 '25 21:09 rnett

Presumably the generated serializer is aware of the defaults in some way and can produce them when required, so it could look something like this (very pseudo-code):

The way a generated serializer is aware of the default values is that it extracts the right hand side of the default assignment where needed. This can be highly complex, and nothing stops the value from being megabytes big. Your example code also assumes that the expression doesn't depend on other values

pdvrieze avatar Sep 15 '25 22:09 pdvrieze

Presumably the generated serializer is aware of the defaults in some way and can produce them when required, so it could look something like this (very pseudo-code):

The difference is that the serialize method has access to the Foo instance that is being serialized now. Your approach will work in case there are simple references, but in cases like Foo(val a: String, val b: String = a + "x"), it won't. So our API has to be designed in a way that would distinguish these two cases

sandwwraith avatar Sep 16 '25 10:09 sandwwraith

Your approach will work in case there are simple references, but in cases like Foo(val a: String, val b: String = a + "x"), it won't.

Ah yeah, good point. That seems like it would be difficult-to-impossible to handle.

rnett avatar Sep 16 '25 17:09 rnett