{"id":22937798,"url":"https://github.com/ciderale/kotlin-union-types","last_synced_at":"2025-04-01T19:32:34.750Z","repository":{"id":78439612,"uuid":"201815840","full_name":"ciderale/kotlin-union-types","owner":"ciderale","description":"Serialization of Kotlin Sealed Case Classes","archived":false,"fork":false,"pushed_at":"2020-03-02T21:35:31.000Z","size":74,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":2,"default_branch":"master","last_synced_at":"2025-02-07T12:39:12.818Z","etag":null,"topics":["adt","case-classes","jackson","jackson-json","kotlin","union-types"],"latest_commit_sha":null,"homepage":null,"language":"Kotlin","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ciderale.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2019-08-11T21:16:59.000Z","updated_at":"2022-04-20T19:18:39.000Z","dependencies_parsed_at":"2023-03-13T20:13:44.741Z","dependency_job_id":null,"html_url":"https://github.com/ciderale/kotlin-union-types","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ciderale%2Fkotlin-union-types","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ciderale%2Fkotlin-union-types/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ciderale%2Fkotlin-union-types/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ciderale%2Fkotlin-union-types/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ciderale","download_url":"https://codeload.github.com/ciderale/kotlin-union-types/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":246700020,"owners_count":20819812,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["adt","case-classes","jackson","jackson-json","kotlin","union-types"],"created_at":"2024-12-14T12:14:37.187Z","updated_at":"2025-04-01T19:32:34.740Z","avatar_url":"https://github.com/ciderale.png","language":"Kotlin","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Serialization of Kotlin Sealed Case Classes\n\nThis repository shows how to serialize and deserialize kotlin sealed cases\nclasses using Jackson. 'jackson-module-kotlin' does a good job in general,\nbut lacks some functionality for handling sealed case classes. Those issues\ncan however be resolved with custom configurations.\n\nThe goal is to properly handle _sealed case classes_ like the following:\n```kotlin\nsealed class SealedClass {\n  data class A(val name: String) : SealedClass()\n  data class B(val name: Double, val age: Int) : SealedClass()\n  object C : SealedClass()\n}\n```\nSome desirable properties for an object mapper are:\n* (MUST) A value that is serialized and deserialized compares equal to the initial value, i.e.\n`assertThat(deserialize(serialized(value)), equalTo(value))`\n* (SHOULD) The amount of boiler plate on such domain classes is kept minimal (ideally none at all)\n* (COULD) The class name (\"A\", \"B\", or \"C\") is used as-is in the json string, without further prefixes.\n\n## Issues without custom mapper configuration\n\nWithout a custom configuration of the object mapper, there are several issues:\n* Deserialization of 'kotlin object' creates new instances, ie. \n`assertThat(deserialize(serialized(C)), equalTo(C))` will fail. That means, the must property is not fulfilled.\n* Deserialization of a value into `SealedClass` fails because the serialized json value does not contain type information (i.e. is it A, B, or C).\n\nThese issues might be resolvable by annotations on `SealedClass` and the subtypes `A`, `B`, and `C`.\nHowever, that would be verbose and clutter an otherwise concise domain representation. \nTherefore, we aim for a solution that works that configures the object mapper to work without annotations.\n\n# Proposed solution for handling sealed cases classes\n\nThis repository presents a solution based on a single marker interface that fulfills the three initially\nstated properties. The solution has minimal boilerplate and the class naming strategy can be \nconfigured once and for all case classes. Most importantly, it correctly deserializes an previously serialized value.\n\nThe following example demonstrates the solution. A fully runnable example is provided\nin `SealedCaseClassesTest`. Implementation details and discussion are provided after\nthe example.\n\n## Example\n\nDefine a single marker interface in your domain module.\n```kotlin\ninterface Tagged  { // any name would work\n  // with the desired class naming strategy\n  val tag: String get() = this.javaClass.simpleName\n}\n```\nThis needs to be done once and can be reused for every sealed case class.\n\nAnnotate all your sealed case classes with this marker interface\n```kotlin\nsealed class SealedClass : Tagged { // mark the class\n  data class A(val name: String) : SealedClass()\n  data class B(val name: Double, val age: Int) : SealedClass()\n  object C : SealedClass()\n}\n```\nNot that this marking is the only thing that needs to be repeated for every case class.\nThe marking is however almost invisible and thus not very distracting.\n\nAnother one-time setup is the configuration of the object mapper.\nIt ensures the correct handling of kotlin's \"object\" type and provides the necessary\ntype information needed for deserialization:\n```kotlin\nval mapper: ObjectMapper = jacksonObjectMapper()\n        // handling of type ids in sealed case classes\n        .registerModule(SimpleModule().apply {\n            setMixInAnnotation(Tagged::class.java,\n                SealedCaseClassesSimpleNameIdMixin::class.java)\n        })\n        // ensure that kotlin objects are treated as singletons\n        .registerModule(SimpleModule().apply {\n            setDeserializerModifier(object : BeanDeserializerModifier() {\n                override fun modifyDeserializer(\n                    config: DeserializationConfig,\n                    beanDesc: BeanDescription,\n                    deserializer: JsonDeserializer\u003c*\u003e\n                ) = super.modifyDeserializer(config, beanDesc, deserializer)\n                    .maybeSingleton(beanDesc.beanClass)\n            })\n        })\n```\n\nGiven this configuration, the mapper is ready to be used, like\n```kotlin\n  val someList:List\u003cSealedClass\u003e = listOf(\n            SealedClass.A(\"Class A\"),\n            SealedClass.B(3.14, 23),\n            SealedClass.C)\n  println(mapper.writeValueAsString(someList))\n```\nwhich results in\n```json\n   [ {\n     \"tag\" : \"A\",\n     \"name\" : \"Class A\"\n   }, {\n     \"tag\" : \"B\",\n     \"name\" : 3.14,\n     \"age\" : 23\n   }, {\n     \"tag\" : \"C\"\n   } ]\n```\n\n## Discussion\n\n### Pro\n- The solution properly serializes and deserializes sealed cases classes (see Tests).\n- The solution has almost no boilerplate on the domain classes. \n  Sealed case classes only need to implement the marker interface, no further configuration needed.\n- The one-time setup for this solution is small. It suffices to\n  * define the marker interface (with default method), and to\n  * configure the object mapper, both almost copy-paste activities.\n- The configuration effort is independent of the actual number of sealed case classes used.\nThe maker interface fully decouples the domain objects from the serialization logic.\nThat means, adding a new case class does not require additional configuration, besides implementing\nthe marker interface.\n- The naming strategy for the included type information can be defined as needed\n- The domain code does not depend on the jackson library. This is interesting from a dependency \ninversion point of view -- the core logic is independent of the serialization. However, the marker\ninterface could also be provided by jackson, if the dependency is not an issue.\n\n### Cons\n\nThere is one minor caveat with this solution. \nThere is some duplication between the marker interface and the `@JsonTypeInfo` annotation used\non the `SealedCaseClassesSimpleNameIdMixin`. Both, the tag name and the naming strategy must be in-sync. \nThis seems to be benign though, as those two things are usually defined once and unlikely to change. \nAnd even changing them would be simple.\n\n### Other options\n\nMaybe there are other options to achieve the same. If you know a better solution, please let me know.\n\n# Implementation details\n\n## Handling of 'object' Singleton Type\n\nKotlin 'object' are meant to be singletons. However, the standard jackson\ndeserialization yields new object instances which are not considered equal ('=='). \nThis can cause subtle problem when values are not compared with the 'is' operator.\n\nThis repository provides a BeanDeserializerModifier that ensures that no \"new\nsingletons\" are exposed. The `KotlinObjectSingletonDeserializer` uses the\nnormal deserializer, but always returns the \"canonical\" singleton object (that\nkotlin defines).\n\nThis ensures that there is just one singleton object accessible and hence\ncomparison (using `==`) work as expected.\n\nBy wrapping the standard deserializer, the 'object' internals (in case of mutable state) \nare deserialized as without using `KotlinObjectSingletonDeserializer`. One can argue that \nobject singletons with mutable state should not be serialized directly, but that\ndiscussion is not the scope of this expose. In fact, typical `object` in case classes\nare more like an enum constant without mutable member. In that case, deserialization could \nalso skip any json content, but the type information.\n\n## @JsonTypeInfo to include type information\n\nThis annotation allows to include type information into the serialized value.\nThis is crucial to deserialize a json object into the appropriate case class.\nHowever, there are two issues that will be addressed in the next two subsections.\n\nFirst, the type information is not included when type information is lost due to type erasure. \nSecond, there are only three built-in naming strategies and none of which uses the inner most class name alone. \nAll three naming strategies seem to include to include at least the name of the outer classes \nseparated by `$` (e.g. `SealedClass$B`) which is a java specific implementation detail.\n\n### Type information despite type erasure for List\u003cT\u003e\n\nDue to type erasure, serialization of (top-level) `List\u003cT\u003e` and similar constructs \nrequires additional tricks. The problem is that the concrete type `T` will be erased. \nHence, the sub type information will not always be included in the serialization output. \n\nThere are two solutions to handle this problem:\n* provide explicit type information to the serializer \n(`mapper.writerFor(jacksonTypeRef\u003cList\u003cSealedClass\u003e\u003e())`)\n* explicitly define type information as class property\n\nThe former is maybe cleaner, but maybe not always possible, depending on the web framework in use.\nThe latter can be achieved with a default implementation in the marker interface. This works well,\nbut requires a duplication of the naming strategy, which seems to be only a minor caveat.\n\nThis issue is demonstrates in `SealedCaseClassesAlmostTest`.\nThat solution has a true marker interface (without default method)\nbut fails at serialization of top-level lists without explicitly \nproviding type information to the object mapper.\n\n### @JsonTypeIdResolver for custom naming strategy\n\nThe benefit of sealed case classes is that the possible sub-types are statically known.\n\nThis can be leveraged to define almost arbitrary naming strategies. Since all sub types\ncan be enumerated from the base class (e.g. `SealedClass`), the naming strategy must only\nguarantee to differentiate between the possible sub types. That is, it does not have to \nbe unique across the entire code base. Hence, neither outer class or package information \nneeds to be included.\n\nHence, it is possible to use just the name of the inner most classes. \nThis works as long as all cases of the sealed case class have unique names.\nBut of course, more elaborated nameing schemes can be deployed as needed.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fciderale%2Fkotlin-union-types","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fciderale%2Fkotlin-union-types","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fciderale%2Fkotlin-union-types/lists"}