SerialDescriptor

Serial descriptor is an inherent property of KSerializer that describes the structure of the serializable type. The structure of the serializable type is not only the property of the type, but also of the serializer as well, meaning that one type can have multiple descriptors that have completely different structure.

For example, the class class Color(val rgb: Int) can have multiple serializable representations, such as {"rgb": 255}, "#0000FF", [0, 0, 255] and {"red": 0, "green": 0, "blue": 255}. Representations are determined by serializers and each such serializer has its own descriptor that identifies each structure in a distinguishable and format-agnostic manner.

Structure

Serial descriptor is identified by its name and consists of kind, potentially empty set of children elements and additional metadata.

  • serialName uniquely identifies the descriptor (and the corresponding serializer) for non-generic types. For generic types, the actual type substitution is omitted from the string representation and the name identifies the family of the serializers without type substitutions. However, type substitution is accounted in equals and hashCode operations, meaning that descriptors of generic classes with the same name, but different type parameters, are not equal to each other. serialName is typically used to specify the type of the target class during serialization of polymorphic and sealed classes, for observability and diagnostics.

  • Kind defines what this descriptor represents: primitive, enum, object, collection et cetera.

  • Children elements are represented as serial descriptors as well and define the structure of the type's elements.

  • Metadata carries additional potentially useful information, such as nullability, optionality and serial annotations.

Usages

There are two general usages of the descriptors: THE serialization process and serialization introspection.

Serialization

Serial descriptor is used as bridge between decoders/encoders and serializers. When asking for a next element, the serializer provides an expected descriptor to the decoder, and, based on the descriptor content, decoder decides how to parse its input. In JSON, for example, when the encoder is asked to encode the next element and this element is a subtype of List, the encoder receives a descriptor with StructureKind.LIST and, based on that, first writes an opening square bracket before writing the content of the list.

Serial descriptor encapsulates the structure of the data, so serializers can be free from format-specific details. ListSerializer knows nothing about JSON and square brackets, providing only the structure of the data and delegating encoding decision to the format itself.

Introspection

Another usage of a serial descriptor is type introspection without its serialization. Introspection can be used to check, whether the given serializable class complies the corresponding scheme and to generate JSON or ProtoBuf schema from the given class.

Indices

Serial descriptor API operates with children indices. For the fixed-size structures, such as regular classes, index is represented by a value in the range from zero to elementsCount and represent and index of the property in this class. Consequently, primitives do not have children and their element count is zero.

For collections and maps, though, indices does not have fixed bound. Regular collections descriptors usually have one element (T, maps have two, one for keys and one for values), but potentially unlimited number of actual children values. Valid indices range is not known statically and implementations of descriptor should provide consistent and unbounded names and indices.

In practice, for regular classes it is allowed to invoke getElement*(index) methods with an index within 0 until elementsCount range and element at the particular index corresponds to the serializable property at the given position. For collections and maps, index parameter for getElement*(index) methods is effectively bound by the maximal number of collection/map elements.

Thread-safety and mutability

Serial descriptor implementation should be immutable and, thus, thread-safe.

Equality and caching

Serial descriptor can be used as a unique identifier for format-specific data or schemas and this implies the following restrictions on its equals and hashCode:

An equals implementation should use both serialName and elements structure. Comparing elementDescriptors directly is discouraged, because it may cause a stack overflow error, e.g. if a serializable class T contains elements of type T. To avoid it, a serial descriptor implementation should compare only descriptors of class' type parameters, in a way that serializer<Box<Int>>().descriptor != serializer<Box<String>>().descriptor. If type parameters are equal, descriptors structure should be compared by using children elements descriptors' serialNames, which correspond to class names (do not confuse with elements own names, which correspond to properties names); and/or other SerialDescriptor properties, such as kind. An example of equals implementation:

if (this === other) return true
if (other::class != this::class) return false
if (serialName != other.serialName) return false
if (!typeParametersAreEqual(other)) return false
if (this.elementDescriptors().map { it.serialName } != other.elementDescriptors().map { it.serialName }) return false
return true

hashCode implementation should use the same properties for computing the result.

User-defined serial descriptors

The best way to define a custom descriptor is to use SerialDescriptor builder function, where for each serializable property corresponding element is declared.

Example:

// Class with custom serializer and custom serial descriptor
class Data(
val intField: Int, // This field is ignored by custom serializer
val longField: Long, // This field is written as long, but in serialized form is named as "_longField"
val stringList: List<String> // This field is written as regular list of strings
)

// Descriptor for such class:
SerialDescriptor("my.package.Data") {
// intField is deliberately ignored by serializer -- not present in the descriptor as well
element<Long>("_longField") // longField is named as _longField
element("stringField", listDescriptor<String>())
}

For a classes that are represented as a single primitive value, PrimitiveSerialDescriptor builder function can be used instead.

Not stable for inheritance

SerialDescriptor interface is not stable for inheritance in 3rd party libraries, as new methods might be added to this interface or contracts of the existing methods can be changed. This interface is safe to build using buildClassSerialDescriptor and PrimitiveSerialDescriptor, and is safe to delegate implementation to existing instances.

interface SerialDescriptor

Functions

equals
Link copied to clipboard
common
open operator override fun equals(other: Any?): Boolean
getElementAnnotations
Link copied to clipboard
common

Returns serial annotations of the child element at the given index. This method differs from getElementDescriptor(index).annotations by reporting only declaration-specific annotations:

@Serializable
@SomeSerialAnnotation
class Nested(...)

@Serializable
class Outer(@AnotherSerialAnnotation val nested: Nested)

outerDescriptor.getElementAnnotations(0) // Returns [@AnotherSerialAnnotation]
outerDescriptor.getElementDescriptor(0).annotations // Returns [@SomeSerialAnnotation]

Only annotations marked with SerialInfo are added to the resulting list.

abstract fun getElementAnnotations(index: Int): List<Annotation>
getElementDescriptor
Link copied to clipboard
common

Retrieves the descriptor of the child element for the given index. For the property of type T on the position i, getElementDescriptor(i) yields the same result as for T.serializer().descriptor, if the serializer for this property is not explicitly overridden with @Serializable(with = ...)`, Polymorphic or Contextual. This method can be used to completely introspect the type that the current descriptor describes.

abstract fun getElementDescriptor(index: Int): SerialDescriptor
getElementIndex
Link copied to clipboard
common

Returns an index in the children list of the given element by its name or CompositeDecoder.UNKNOWN_NAME if there is no such element. The resulting index, if it is not CompositeDecoder.UNKNOWN_NAME, is guaranteed to be usable with getElementName.

abstract fun getElementIndex(name: String): Int
getElementName
Link copied to clipboard
common

Returns a positional name of the child at the given index. Positional name represents a corresponding property name in the class, associated with the current descriptor.

abstract fun getElementName(index: Int): String
hashCode
Link copied to clipboard
common
open override fun hashCode(): Int
isElementOptional
Link copied to clipboard
common

Whether the element at the given index is optional (can be absent is serialized form). For generated descriptors, all elements that have a corresponding default parameter value are marked as optional. Custom serializers can treat optional values in a serialization-specific manner without default parameters constraint.

Example of optionality:

@Serializable
class Holder(
val a: Int, // Optional == false
val b: Int?, // Optional == false
val c: Int? = null, // Optional == true
val d: List<Int>, // Optional == false
val e: List<Int> = listOf(1), // Optional == true
)

Returns false for valid indices of collections, maps and enums.

abstract fun isElementOptional(index: Int): Boolean
toString
Link copied to clipboard
common
open override fun toString(): String

Properties

annotations
Link copied to clipboard
common

Returns serial annotations of the associated class. Serial annotations can be used to specify an additional metadata that may be used during serialization. Only annotations marked with SerialInfo are added to the resulting list.

open val annotations: List<Annotation>
elementsCount
Link copied to clipboard
common

The number of elements this descriptor describes, besides from the class itself. elementsCount describes the number of semantic elements, not the number of actual fields/properties in the serialized form, even though they frequently match.

For example, for the following class class Complex(val real: Long, val imaginary: Long) the corresponding descriptor and the serialized form both have two elements, while for class IntList : ArrayList<Int>() the corresponding descriptor has a single element (IntDescriptor, the type of list element), but from zero up to Int.MAX_VALUE values in the serialized form.

abstract val elementsCount: Int
isInline
Link copied to clipboard
common

Returns true if this descriptor describes a serializable inline class.

open val isInline: Boolean
isNullable
Link copied to clipboard
common

Whether the descriptor describes nullable element. Returns true if associated serializer can serialize/deserialize nullable elements of the described type.

open val isNullable: Boolean
kind
Link copied to clipboard
common

The kind of the serialized form that determines the shape of the serialized data. Formats use serial kind to add and parse serializer-agnostic metadata to the result.

For example, JSON format wraps classes and StructureKind.MAP into brackets, while ProtoBuf just serialize these types in separate ways.

Kind should be consistent with the implementation, for example, if it is a primitive, then its elements count should be zero and vice versa.

abstract val kind: SerialKind
serialName
Link copied to clipboard
common

Serial name of the descriptor that identifies pair of the associated serializer and target class.

For generated serializers, serial name is equal to the corresponding class's fully-qualified name or, if overridden, SerialName. Custom serializers should provide a unique serial name that identify both the serializable class and the serializer itself, ignoring type arguments, if they are present.

abstract val serialName: String

Extensions

capturedKClass
Link copied to clipboard
common

Retrieves KClass associated with serializer and its descriptor, if it was captured.

For schema introspection purposes, capturedKClass can be used in SerializersModule as a key to retrieve registered descriptor at runtime. This property is intended to be used on SerialKind.CONTEXTUAL and PolymorphicKind.OPEN kinds of descriptors, where actual serializer used for a property can be determined only at runtime. Serializers which represent contextual serialization and open polymorphism (namely, ContextualSerializer and PolymorphicSerializer) capture statically known KClass in a descriptor and can expose it via this property.

This property is null for descriptors that are not of SerialKind.CONTEXTUAL or PolymorphicKind.OPEN kinds. It may be null for descriptors of these kinds, if captured class information is unavailable for various reasons. It means that schema introspection should be performed in an application-specific manner.

Example

Imagine we need to find all distinct properties names, which may occur in output after serializing a given class with respect to @Contextual annotation and all possible inheritors when the class is serialized polymorphically. Then we can write following function:

fun allDistinctNames(descriptor: SerialDescriptor, module: SerialModule) = when (descriptor.kind) {
is PolymorphicKind.OPEN -> module.getPolymorphicDescriptors(descriptor)
.map { it.elementNames() }.flatten().toSet()
is SerialKind.CONTEXTUAL -> module.getContextualDescriptor(descriptor)
?.elementNames().orEmpty().toSet()
else -> descriptor.elementNames().toSet()
}

val SerialDescriptor.capturedKClass: KClass<*>?
elementDescriptors
Link copied to clipboard
common

Returns an iterable of all descriptor elements.

val SerialDescriptor.elementDescriptors: Iterable<SerialDescriptor>
elementNames
Link copied to clipboard
common

Returns an iterable of all descriptor element names.

val SerialDescriptor.elementNames: Iterable<String>
nullable
Link copied to clipboard
common

Returns new serial descriptor for the same type with isNullable property set to true.

val SerialDescriptor.nullable: SerialDescriptor