dyno

Type-safe, serializable heterogeneous map with typed keys, mutable/immutable variants, automatic JSON serialization, schema-driven validation, eager/lazy deserialization, and class-keyed polymorphic maps.

JVMKotlin/NativeWasmJS
GitHub stars1
Authorsadokky
Dependents0
OSS Health
Creation date8 months ago

Last activity4 months ago
Latest release0.12.0 (5 months ago)

Maven Central Version javadoc GitHub license

DynamicObject

Type-safe, serializable, heterogeneous map.

  • Type-safety: work with dynamic objects using strictly typed keys (DynoKey<T>).
  • Automatic JSON serialization: out-of-the-box support for Json format of kotlinx.serialization.

Quick Example

// Define typed keys
object Person {
    val name by dynoKey<String>()
    val age by dynoKey<Int>()
    val emails by dynoKey<List<String>?>()
}

val person = dynamicObjectOf(
    Person.name with "Alex",
    Person.age with 42,
    Person.emails with listOf("alex@example.com")
)

// Type-safe accessors
val name   = person[Person.name]    // String
val age    = person[Person.age]     // Int
val emails = person[Person.emails]  // List<String>?

// Serialization support
val json = Json.encodeToString(person)
val restored = Json.decodeFromString<DynamicObject>(json)

Serialized form:

{
  "name": "Alex",
  "age": 31,
  "emails": ["alex@example.com"]
}

Module core

implementation("io.github.adokky:dyno-core:0.12.0")

DynoKey<T>

A typed key used to access values in DynamicObject and MutableDynamicObject. Can be instantiated directly or by using a delegate dynoKey.

object Person {
    val id = DynoKey<Int>("id")
    val name by dynoKey<String>()
    val age by dynoKey<Int?>()
}

DynamicObject

The main class representing an immutable, type-safe, and serializable heterogeneous map. It allows storing and retrieving values of different types using typed keys (DynoKey).

Example:

val obj = dynamicObjectOf(
    Person.id with 42,
    Person.name with "Alex",
    Person.age with 30
)

val name: Int? = obj[Person.age]

If the type parameter T in DynoKey<T> is not nullable, then the return type is also non-nullable. NoSuchDynoKeyException is thrown if the key does not exist:

val id: Int = obj[Person.id]

MutableDynamicObject

A mutable variant of DynamicObject that allows adding, updating, and removing key-value pairs. Shares the same type-safety and serialization features.

val obj = mutableDynamicObjectOf(Person.name with "Bob")
obj[Person.age] = 25
obj -= Person.name

Module classmap

implementation("io.github.adokky:dyno-classmap:0.12.0")

ClassMap<T>

A specialized map implementation that associates T / KClass<T> keys with values of serializable type T.

val map: ClassMap = buildClassMap {
    put("foo")
    put(42)
}

map.get<String>() // returns "foo"
map.get<Int>() // returns 42

In JSON, serial name of T used as a key:

@Serializable
@SerialName("user")
data class User(val name: String, val age: Int)

@Serializable
@SerialName("account")
data class Account(val id: Int, val active: Boolean)

val map: ClassMap = buildClassMap {
    put(User("Alex", 30))
    put(Account(123, true))
}

Json.encodeToString(ClassMapSerializer, map)
{
    "user": {
        "name": "Alex",
        "age": 30
    },
    "account": {
        "id": 123,
        "active": true
    }
}

For working with any DynoMap (including DynamicObject), functions like getInstance/setInstance are available, for example:

val obj = mutableDynamicObjectOf()
obj.setInstance(User("Bob", 25))
val user: User? = obj.getInstance()

These functions allow interacting with class instances without explicitly specifying keys. However:

  • ClassMap only accept classes/types as the keys, while DynoMap is only restricted by its type argument.
  • ClassMap provides more ergonomics by leveraging standard method names and built-in operators

TypedClassMap<T>

A variant of ClassMap that restricts keys to only subclasses of a specified base class T.

abstract class Animal
@Serializable data class Dog(val name: String) : Animal()
@Serializable data class Cat(val name: String) : Animal()

val map: TypedClassMap<Animal> = buildTypedClassMap {
    put(Dog("Buddy"))
    put(Cat("Whiskers"))
}

val dog: Dog? = map.get<Dog>()
val cat: Cat? = map.get<Cat>()

// compilation error: String is not a subtype of Animal
map.put("string")

Module schema

The dyno-schema module provides a powerful way to define and work with structured, validated dynamic objects using schemas. It brings compile-time safety and structural validation to DynamicObject.

  • Structural validation: Ensures required fields are present.
  • Eager deserialization: Fields ar`e validated and deserialized immediately, catching errors early.
  • Polymorphic support: Define and work with polymorphic schemas for flexible data structures.
  • Type-safe entities: Ensures that only keys compatible with the defined schema can be used, preventing runtime errors from mismatched types.
object Person : SimpleDynoSchema("person") {
    val name by dynoKey<String>()
    val age by dynoKey<Int>()
}

// Create structurally validated entity.
// All required fields must be present.
val person = Person.new {
    name set "Alex"
    age set 30
}

// Throws MissingFieldsException
Json.decodeFromString(Person, """{"age":30}""")

person[Person.name] // OK
person[OtherSchema.name] // compilation error

For full documentation and usage examples, see schema/README.md.

Serialization

Dyno allows both lazy and eager deserialization modes, allowing you to choose the approach that best fits your performance and usability requirements.

Lazy (default)

  • Works out-of-the-box: no additional setup required for basic usage.
  • Schema-less by design: easily extendable without pre-registering keys.
  • May be slower than eager deserialization, due to intermediate representation storage.

The following classes are automatically serializable without any extra steps:

  • DynamicObject
  • MutableDynamicObject
  • ClassMap
  • MutableClassMap

The following classes have type arguments, so you may need to specify an explicit serializer if type argument is not serializable:

  • DynoMap
  • MutableDynoMap
  • TypedClassMap
  • MutableTypedClassMap

Note: Annotations like @Serializable(with=Serializer::class) or @Contextual do not work inside function type arguments. For example, this will not work: Json.decodeFromString<TypedClassMap<@Contextual Any>>("{}")

To serialize these classes using Json.encodeToString or Json.decodeFromString, you must specify an explicit serializer. For example, MutableClassMap should be serialized like this:

Json.encodeToString(MutableTypedClassMapSerializer, mutableTypedClassMap)

Lazy Serializer Mapping

Class Serializer
TypedClassMap TypedClassMapSerializer
MutableTypedClassMap MutableTypedClassMapSerializer
DynoMap DynoMapMapSerializer
MutableDynoMap MutableDynoMapSerializer

Eager Deserialization

Achieved with AbstractEagerDynoSerializer — a powerful base class for implementing eager deserialization strategies.

  • Direct deserialization. unlike lazy serialization, which stores intermediate JsonElement representations, eager serialization decodes values directly into their final types, offering better memory efficiency.
  • Mixed strategies: supports mixing eager and lazy (JsonElement) deserialization strategies within the same object by returning ResolveResult.Keep.
  • Polymorphic handling: delay deserialization of keys that depend on other fields using ResolveResult.Delay, enabling polymorphic or conditional deserialization.

Usage example.

DynoKey validation

The onAssign and onDecode processors can be assigned to add validation logic. All processors are chained in the order of assignment.

  • onAssign is called when a value is manually assigned to the key (e.g., obj[key] = value or dynamicObjectOf(key with value)).
  • onDecode is called when a value is deserialized. Useful when validation is only needed for deserialized objects received from network.
  • validate assigns the same validation logic to both onAssign and onDecode.

Example

object Person {
    val age by dynoKey<Int>().onDecode { 
        require(it > 0) { "'age' must be positive, but was: $it" } 
    }
    val name by dynoKey<String>().validate {
        require(it.isNotBlank()) { "'name' must not be empty" }
    }
}

Decoding from JSON - both onDecode and validate processors are called:

val obj = Json.decodeFromString<DynamicObject>("""{"name": "", "age": -1}""")
decoded[Person.age]  // throws IllegalArgumentException
decoded[Person.name] // throws IllegalArgumentException

Manual assignment - only validate processor is called:

val obj = mutableDynamicObjectOf(Person.age with -1)
obj[Person.age]       // returns -1
obj[Person.name] = "" // throws IllegalArgumentException

// throws IllegalArgumentException
mutableDynamicObjectOf(Person.name with "") 

Composition

The validation functions are easily composable and can be used to build your own validation DSL:

fun <R: DynoKeySpec<String>> R.notBlank() = validate {
    require(it.isNotBlank()) { "property '$name' must not be empty" }
}

fun <R: DynoKeySpec<String>> R.maxLength(max: Int) = validate {
    require(it.length <= max) { "property '$name' length must be <= $max, but was: ${it.length}" }
}

Multiple validators are chained together:

object User {
    val name = DynoKey<String>("username")
        .notBlank()
        .maxLength(100)
        
    val email by dynoKey<String>()
        .validate { require("@" in it) { "property '$name' must be valid email" } }
        .maxLength(255)
}

When a value is assigned or decoded, all validators in the chain are executed in order.

JVMKotlin/NativeWasmJS
GitHub stars1
Authorsadokky
Dependents0
OSS Health
Creation date8 months ago

Last activity4 months ago
Latest release0.12.0 (5 months ago)

Maven Central Version javadoc GitHub license

DynamicObject

Type-safe, serializable, heterogeneous map.

  • Type-safety: work with dynamic objects using strictly typed keys (DynoKey<T>).
  • Automatic JSON serialization: out-of-the-box support for Json format of kotlinx.serialization.

Quick Example

// Define typed keys
object Person {
    val name by dynoKey<String>()
    val age by dynoKey<Int>()
    val emails by dynoKey<List<String>?>()
}

val person = dynamicObjectOf(
    Person.name with "Alex",
    Person.age with 42,
    Person.emails with listOf("alex@example.com")
)

// Type-safe accessors
val name   = person[Person.name]    // String
val age    = person[Person.age]     // Int
val emails = person[Person.emails]  // List<String>?

// Serialization support
val json = Json.encodeToString(person)
val restored = Json.decodeFromString<DynamicObject>(json)

Serialized form:

{
  "name": "Alex",
  "age": 31,
  "emails": ["alex@example.com"]
}

Module core

implementation("io.github.adokky:dyno-core:0.12.0")

DynoKey<T>

A typed key used to access values in DynamicObject and MutableDynamicObject. Can be instantiated directly or by using a delegate dynoKey.

object Person {
    val id = DynoKey<Int>("id")
    val name by dynoKey<String>()
    val age by dynoKey<Int?>()
}

DynamicObject

The main class representing an immutable, type-safe, and serializable heterogeneous map. It allows storing and retrieving values of different types using typed keys (DynoKey).

Example:

val obj = dynamicObjectOf(
    Person.id with 42,
    Person.name with "Alex",
    Person.age with 30
)

val name: Int? = obj[Person.age]

If the type parameter T in DynoKey<T> is not nullable, then the return type is also non-nullable. NoSuchDynoKeyException is thrown if the key does not exist:

val id: Int = obj[Person.id]

MutableDynamicObject

A mutable variant of DynamicObject that allows adding, updating, and removing key-value pairs. Shares the same type-safety and serialization features.

val obj = mutableDynamicObjectOf(Person.name with "Bob")
obj[Person.age] = 25
obj -= Person.name

Module classmap

implementation("io.github.adokky:dyno-classmap:0.12.0")

ClassMap<T>

A specialized map implementation that associates T / KClass<T> keys with values of serializable type T.

val map: ClassMap = buildClassMap {
    put("foo")
    put(42)
}

map.get<String>() // returns "foo"
map.get<Int>() // returns 42

In JSON, serial name of T used as a key:

@Serializable
@SerialName("user")
data class User(val name: String, val age: Int)

@Serializable
@SerialName("account")
data class Account(val id: Int, val active: Boolean)

val map: ClassMap = buildClassMap {
    put(User("Alex", 30))
    put(Account(123, true))
}

Json.encodeToString(ClassMapSerializer, map)
{
    "user": {
        "name": "Alex",
        "age": 30
    },
    "account": {
        "id": 123,
        "active": true
    }
}

For working with any DynoMap (including DynamicObject), functions like getInstance/setInstance are available, for example:

val obj = mutableDynamicObjectOf()
obj.setInstance(User("Bob", 25))
val user: User? = obj.getInstance()

These functions allow interacting with class instances without explicitly specifying keys. However:

  • ClassMap only accept classes/types as the keys, while DynoMap is only restricted by its type argument.
  • ClassMap provides more ergonomics by leveraging standard method names and built-in operators

TypedClassMap<T>

A variant of ClassMap that restricts keys to only subclasses of a specified base class T.

abstract class Animal
@Serializable data class Dog(val name: String) : Animal()
@Serializable data class Cat(val name: String) : Animal()

val map: TypedClassMap<Animal> = buildTypedClassMap {
    put(Dog("Buddy"))
    put(Cat("Whiskers"))
}

val dog: Dog? = map.get<Dog>()
val cat: Cat? = map.get<Cat>()

// compilation error: String is not a subtype of Animal
map.put("string")

Module schema

The dyno-schema module provides a powerful way to define and work with structured, validated dynamic objects using schemas. It brings compile-time safety and structural validation to DynamicObject.

  • Structural validation: Ensures required fields are present.
  • Eager deserialization: Fields ar`e validated and deserialized immediately, catching errors early.
  • Polymorphic support: Define and work with polymorphic schemas for flexible data structures.
  • Type-safe entities: Ensures that only keys compatible with the defined schema can be used, preventing runtime errors from mismatched types.
object Person : SimpleDynoSchema("person") {
    val name by dynoKey<String>()
    val age by dynoKey<Int>()
}

// Create structurally validated entity.
// All required fields must be present.
val person = Person.new {
    name set "Alex"
    age set 30
}

// Throws MissingFieldsException
Json.decodeFromString(Person, """{"age":30}""")

person[Person.name] // OK
person[OtherSchema.name] // compilation error

For full documentation and usage examples, see schema/README.md.

Serialization

Dyno allows both lazy and eager deserialization modes, allowing you to choose the approach that best fits your performance and usability requirements.

Lazy (default)

  • Works out-of-the-box: no additional setup required for basic usage.
  • Schema-less by design: easily extendable without pre-registering keys.
  • May be slower than eager deserialization, due to intermediate representation storage.

The following classes are automatically serializable without any extra steps:

  • DynamicObject
  • MutableDynamicObject
  • ClassMap
  • MutableClassMap

The following classes have type arguments, so you may need to specify an explicit serializer if type argument is not serializable:

  • DynoMap
  • MutableDynoMap
  • TypedClassMap
  • MutableTypedClassMap

Note: Annotations like @Serializable(with=Serializer::class) or @Contextual do not work inside function type arguments. For example, this will not work: Json.decodeFromString<TypedClassMap<@Contextual Any>>("{}")

To serialize these classes using Json.encodeToString or Json.decodeFromString, you must specify an explicit serializer. For example, MutableClassMap should be serialized like this:

Json.encodeToString(MutableTypedClassMapSerializer, mutableTypedClassMap)

Lazy Serializer Mapping

Class Serializer
TypedClassMap TypedClassMapSerializer
MutableTypedClassMap MutableTypedClassMapSerializer
DynoMap DynoMapMapSerializer
MutableDynoMap MutableDynoMapSerializer

Eager Deserialization

Achieved with AbstractEagerDynoSerializer — a powerful base class for implementing eager deserialization strategies.

  • Direct deserialization. unlike lazy serialization, which stores intermediate JsonElement representations, eager serialization decodes values directly into their final types, offering better memory efficiency.
  • Mixed strategies: supports mixing eager and lazy (JsonElement) deserialization strategies within the same object by returning ResolveResult.Keep.
  • Polymorphic handling: delay deserialization of keys that depend on other fields using ResolveResult.Delay, enabling polymorphic or conditional deserialization.

Usage example.

DynoKey validation

The onAssign and onDecode processors can be assigned to add validation logic. All processors are chained in the order of assignment.

  • onAssign is called when a value is manually assigned to the key (e.g., obj[key] = value or dynamicObjectOf(key with value)).
  • onDecode is called when a value is deserialized. Useful when validation is only needed for deserialized objects received from network.
  • validate assigns the same validation logic to both onAssign and onDecode.

Example

object Person {
    val age by dynoKey<Int>().onDecode { 
        require(it > 0) { "'age' must be positive, but was: $it" } 
    }
    val name by dynoKey<String>().validate {
        require(it.isNotBlank()) { "'name' must not be empty" }
    }
}

Decoding from JSON - both onDecode and validate processors are called:

val obj = Json.decodeFromString<DynamicObject>("""{"name": "", "age": -1}""")
decoded[Person.age]  // throws IllegalArgumentException
decoded[Person.name] // throws IllegalArgumentException

Manual assignment - only validate processor is called:

val obj = mutableDynamicObjectOf(Person.age with -1)
obj[Person.age]       // returns -1
obj[Person.name] = "" // throws IllegalArgumentException

// throws IllegalArgumentException
mutableDynamicObjectOf(Person.name with "") 

Composition

The validation functions are easily composable and can be used to build your own validation DSL:

fun <R: DynoKeySpec<String>> R.notBlank() = validate {
    require(it.isNotBlank()) { "property '$name' must not be empty" }
}

fun <R: DynoKeySpec<String>> R.maxLength(max: Int) = validate {
    require(it.length <= max) { "property '$name' length must be <= $max, but was: ${it.length}" }
}

Multiple validators are chained together:

object User {
    val name = DynoKey<String>("username")
        .notBlank()
        .maxLength(100)
        
    val email by dynoKey<String>()
        .validate { require("@" in it) { "property '$name' must be valid email" } }
        .maxLength(255)
}

When a value is assigned or decoded, all validators in the chain are executed in order.