diff --git a/docs/formats.md b/docs/formats.md
index ec4e6cb82..f4464fea0 100644
--- a/docs/formats.md
+++ b/docs/formats.md
@@ -16,6 +16,7 @@ stable, these are currently experimental features of Kotlin Serialization.
* [Definite vs. Indefinite Length Encoding](#definite-vs-indefinite-length-encoding)
* [Tags and Labels](#tags-and-labels)
* [Arrays](#arrays)
+ * [Nullability of Properties](#nullability-of-properties)
* [Custom CBOR-specific Serializers](#custom-cbor-specific-serializers)
* [ProtoBuf (experimental)](#protobuf-experimental)
* [Field numbers](#field-numbers)
@@ -308,6 +309,10 @@ When annotated with `@CborArray`, serialization of the same object will produce
```
This may be used to encode COSE structures, see [RFC 9052 2. Basic COSE Structure](https://www.rfc-editor.org/rfc/rfc9052#section-2).
+### Nullability of Properties
+Some standards, like COSE, tend to encode the absence of a complex property as an empty map (because the complex property itself
+consists only of nullable properties). This cannot be modelled elegantly, such that the null-safety fo Kotlin can be leveraged.
+To work around this, complex nullable properties can be annotated with [`@CborNullAsEmptyMap`](CborNullAsEmptyMap.kt), to emulate this behaviour.
### Custom CBOR-specific Serializers
Cbor encoders and decoders implement the interfaces [CborEncoder](CborEncoder.kt) and [CborDecoder](CborDecoder.kt), respectively.
diff --git a/docs/serialization-guide.md b/docs/serialization-guide.md
index c01eb6231..0ba5eb3a5 100644
--- a/docs/serialization-guide.md
+++ b/docs/serialization-guide.md
@@ -154,6 +154,7 @@ Once the project is set up, we can start serializing some classes.
* [Definite vs. Indefinite Length Encoding](formats.md#definite-vs-indefinite-length-encoding)
* [Tags and Labels](formats.md#tags-and-labels)
* [Arrays](formats.md#arrays)
+ * [Nullability of Properties](formats.md#nullability-of-properties)
* [Custom CBOR-specific Serializers](formats.md#custom-cbor-specific-serializers)
* [ProtoBuf (experimental)](formats.md#protobuf-experimental)
* [Field numbers](formats.md#field-numbers)
diff --git a/formats/cbor/api/kotlinx-serialization-cbor.api b/formats/cbor/api/kotlinx-serialization-cbor.api
index 6b580add0..d9a5c337d 100644
--- a/formats/cbor/api/kotlinx-serialization-cbor.api
+++ b/formats/cbor/api/kotlinx-serialization-cbor.api
@@ -101,6 +101,13 @@ public final synthetic class kotlinx/serialization/cbor/CborLabel$Impl : kotlinx
public final synthetic fun label ()J
}
+public abstract interface annotation class kotlinx/serialization/cbor/CborNullAsEmptyMap : java/lang/annotation/Annotation {
+}
+
+public final synthetic class kotlinx/serialization/cbor/CborNullAsEmptyMap$Impl : kotlinx/serialization/cbor/CborNullAsEmptyMap {
+ public fun ()V
+}
+
public final class kotlinx/serialization/cbor/CborTag {
public static final field BASE16 J
public static final field BASE64 J
diff --git a/formats/cbor/api/kotlinx-serialization-cbor.klib.api b/formats/cbor/api/kotlinx-serialization-cbor.klib.api
index 346416c8f..b55acf628 100644
--- a/formats/cbor/api/kotlinx-serialization-cbor.klib.api
+++ b/formats/cbor/api/kotlinx-serialization-cbor.klib.api
@@ -21,6 +21,10 @@ open annotation class kotlinx.serialization.cbor/CborLabel : kotlin/Annotation {
final fun (): kotlin/Long // kotlinx.serialization.cbor/CborLabel.label.|(){}[0]
}
+open annotation class kotlinx.serialization.cbor/CborNullAsEmptyMap : kotlin/Annotation { // kotlinx.serialization.cbor/CborNullAsEmptyMap|null[0]
+ constructor () // kotlinx.serialization.cbor/CborNullAsEmptyMap.|(){}[0]
+}
+
open annotation class kotlinx.serialization.cbor/KeyTags : kotlin/Annotation { // kotlinx.serialization.cbor/KeyTags|null[0]
constructor (kotlin/ULongArray...) // kotlinx.serialization.cbor/KeyTags.|(kotlin.ULongArray...){}[0]
diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborNullAsEmptyMap.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborNullAsEmptyMap.kt
new file mode 100644
index 000000000..03b46f089
--- /dev/null
+++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/CborNullAsEmptyMap.kt
@@ -0,0 +1,42 @@
+package kotlinx.serialization.cbor
+
+import kotlinx.serialization.*
+
+/**
+ * Marks a complex property to be encoded as an empty map when null, instead of CBOR `null`.
+ *
+ * This is useful for COSE encoding, because COSE known protected and unprotected headers, for example,
+ * and the compiler handles null checks, while checks for empty maps would lead to duplicated spaghetti code.
+ *
+ * Example usage:
+ *
+ * ```
+ *
+ * @Serializable
+ * data class ClassWNullableAsMap(
+ * @SerialName("nullable")
+ * @CborNullAsEmptyMap
+ * val nullable: NullableClass?
+ * )
+ *
+ * @Serializable
+ * data class NullableClass(
+ * val property: String
+ * )
+ *
+ * Cbor.encodeToByteArray(ClassWNullableAsMap(nullable = null))
+ * ```
+ *
+ * will produce bytes `0xbf686e756c6c61626c65a0ff`, or in diagnostic notation:
+ *
+ * ```
+ *a1 # map(1)
+ * 68 # text(8)
+ * 6e756c6c61626c65 # "nullable"
+ * a0 # map(0)
+ * ```
+ */
+@SerialInfo
+@Target(AnnotationTarget.PROPERTY)
+@ExperimentalSerializationApi
+public annotation class CborNullAsEmptyMap
diff --git a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt
index e84fbd8cd..1447e1a27 100644
--- a/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt
+++ b/formats/cbor/commonMain/src/kotlinx/serialization/cbor/internal/Encoder.kt
@@ -28,7 +28,7 @@ internal sealed class CborWriter(
override val cbor: Cbor,
protected val output: ByteArrayOutput,
) : AbstractEncoder(), CborEncoder {
- protected var isClass = false
+ protected var encodeNullAsEmptyMap = false
protected var encodeByteArrayAsByteString = false
@@ -103,7 +103,7 @@ internal sealed class CborWriter(
override fun encodeNull() {
- if (isClass) getDestination().encodeEmptyMap()
+ if (encodeNullAsEmptyMap) getDestination().encodeEmptyMap()
else getDestination().encodeNull()
}
@@ -117,7 +117,7 @@ internal sealed class CborWriter(
override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean {
val destination = getDestination()
- isClass = descriptor.getElementDescriptor(index).kind == StructureKind.CLASS
+ encodeNullAsEmptyMap = descriptor.getElementAnnotations(index).find { it is CborNullAsEmptyMap } != null
encodeByteArrayAsByteString = descriptor.isByteString(index)
val name = descriptor.getElementName(index)
diff --git a/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborNullAsEmptyMapTest.kt b/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborNullAsEmptyMapTest.kt
new file mode 100644
index 000000000..445f97e96
--- /dev/null
+++ b/formats/cbor/commonTest/src/kotlinx/serialization/cbor/CborNullAsEmptyMapTest.kt
@@ -0,0 +1,178 @@
+package kotlinx.serialization.cbor
+
+import kotlinx.serialization.*
+import kotlin.test.*
+
+
+class CborNullAsEmptyMapTest {
+
+
+ @Test
+ fun nullableAsMap() {
+ /**
+ * a1 # map(1)
+ * 68 # text(8)
+ * 6e756c6c61626c65 # "nullable"
+ * a0 # map(0)
+ */
+ val referenceHexString = "a1686e756c6c61626c65a0"
+ val reference = ClassWNullableAsMap(nullable = null)
+
+ val cbor = Cbor.CoseCompliant
+
+ assertEquals(referenceHexString, cbor.encodeToHexString(ClassWNullableAsMap.serializer(), reference))
+ assertEquals(reference, cbor.decodeFromHexString(ClassWNullableAsMap.serializer(), referenceHexString))
+ }
+
+ @Test
+ fun nullableAsNull() {
+ /**
+ * a1 # map(1)
+ * 68 # text(8)
+ * 6e756c6c61626c65 # "nullable"
+ * f6 # null, simple(22)
+ */
+ val referenceHexString = "a1686e756c6c61626c65f6"
+ val reference = ClassWNullableAsNull(nullable = null)
+
+
+ val cbor = Cbor.CoseCompliant
+
+ assertEquals(referenceHexString, cbor.encodeToHexString(ClassWNullableAsNull.serializer(), reference))
+ assertEquals(reference, cbor.decodeFromHexString(ClassWNullableAsNull.serializer(), referenceHexString))
+ }
+
+ @Test
+ fun nullableAsMapWithDefaultNull() {
+ /**
+ * a1 # map(1)
+ * 68 # text(8)
+ * 6e756c6c61626c65 # "nullable"
+ * a0 # map(0)
+ */
+ val referenceHexString = "a1686e756c6c61626c65a0"
+ val reference = ClassWNullableAsMapWithDefaultValueNull()
+
+ val cbor = Cbor {
+ useDefiniteLengthEncoding = true
+ encodeDefaults = true
+ }
+
+ assertEquals(
+ referenceHexString,
+ cbor.encodeToHexString(ClassWNullableAsMapWithDefaultValueNull.serializer(), reference)
+ )
+ assertEquals(
+ reference,
+ cbor.decodeFromHexString(ClassWNullableAsMapWithDefaultValueNull.serializer(), referenceHexString)
+ )
+ }
+
+ @Test
+ fun nullableAsNullWithDefaultNull() {
+ /**
+ * a1 # map(1)
+ * 68 # text(8)
+ * 6e756c6c61626c65 # "nullable"
+ * f6 # null, simple(22)
+ */
+ val referenceHexString = "a1686e756c6c61626c65f6"
+ val reference = ClassWNullableAsNullWithDefaultValueNull()
+
+
+ val cbor = Cbor {
+ useDefiniteLengthEncoding = true
+ encodeDefaults = true
+ }
+
+ assertEquals(
+ referenceHexString,
+ cbor.encodeToHexString(ClassWNullableAsNullWithDefaultValueNull.serializer(), reference)
+ )
+ assertEquals(
+ reference,
+ cbor.decodeFromHexString(ClassWNullableAsNullWithDefaultValueNull.serializer(), referenceHexString)
+ )
+ }
+ @Test
+ fun nullableAsMapWithDefaultNullNoEncodeDefaults() {
+ /**
+ * a0 # map(0)
+ */
+ val referenceHexString = "a0"
+ val reference = ClassWNullableAsMapWithDefaultValueNull()
+
+ val cbor = Cbor {
+ useDefiniteLengthEncoding = true
+ encodeDefaults = false
+ }
+
+ assertEquals(
+ referenceHexString,
+ cbor.encodeToHexString(ClassWNullableAsMapWithDefaultValueNull.serializer(), reference)
+ )
+ assertEquals(
+ reference,
+ cbor.decodeFromHexString(ClassWNullableAsMapWithDefaultValueNull.serializer(), referenceHexString)
+ )
+ }
+
+ @Test
+ fun nullableAsNullWithDefaultNullNoEncodeDefaults() {
+ /**
+ * a0 # map(0)
+ */
+ val referenceHexString = "a0"
+ val reference = ClassWNullableAsNullWithDefaultValueNull()
+
+
+ val cbor = Cbor {
+ useDefiniteLengthEncoding = true
+ encodeDefaults = false
+ }
+
+ assertEquals(
+ referenceHexString,
+ cbor.encodeToHexString(ClassWNullableAsNullWithDefaultValueNull.serializer(), reference)
+ )
+ assertEquals(
+ reference,
+ cbor.decodeFromHexString(ClassWNullableAsNullWithDefaultValueNull.serializer(), referenceHexString)
+ )
+ }
+}
+
+@Serializable
+data class ClassWNullableAsMap(
+ @SerialName("nullable")
+ @CborNullAsEmptyMap
+ val nullable: NullableClass?
+)
+
+@Serializable
+data class ClassWNullableAsMapWithDefaultValueNull(
+ @SerialName("nullable")
+ @CborNullAsEmptyMap
+ val nullable: NullableClass? = null
+)
+
+
+@Serializable
+data class ClassWNullableAsNull(
+ @SerialName("nullable")
+ val nullable: NullableClass?
+)
+
+@Serializable
+data class ClassWNullableAsNullWithDefaultValueNull(
+ @SerialName("nullable")
+ val nullable: NullableClass? = null
+)
+
+@Serializable
+data class NullableClass(
+ val property: String
+)
+
+
+