Skip to content

Jackson Integration

The typed-value-jackson module provides JSON serialization and deserialization for TypedValue types using Jackson.

Built with

  • Jackson 3.0.x

Installation

kotlin
dependencies {
    implementation("com.ekino.oss:typed-value-core:1.1.0")
    implementation("com.ekino.oss:typed-value-jackson:1.1.0")
}

Classes

TypedValueModule

Jackson module that registers TypedValue serializers and deserializers.

kotlin
class TypedValueModule : SimpleModule()

Automatically registers:

  • TypedValueSerializer for all TypedValue types
  • TypedValueDeserializer for TypedValue, TypedUuid, TypedString, TypedLong, TypedInt

TypedValueSerializer

Serializes TypedValue to its raw ID value.

TypedValue TypeJSON Output
TypedString<T>"string-value"
TypedLong<T>12345
TypedInt<T>42
TypedUuid<T>"550e8400-e29b-41d4-a716-446655440000"
TypedValue<Double, T>3.14
TypedValue<Float, T>3.14
Other Number types12345 (as Long)
Other Comparable"string-representation"

TypedValueDeserializer

Deserializes JSON values to TypedValue using contextual type resolution.

Key Features:

  • Uses createContextual() to resolve generic type parameters from property declarations
  • Extracts entity type (T) and ID type from the target type
  • Supports all convenience types and generic TypedValue

Supported ID Types:

ID TypeJSON InputConversion
String"value"Direct use
Long12345 or "12345"Parsed to Long
Int42 or "42"Parsed to Int
UUID"uuid-string"UUID.fromString()
Other Comparable"value"String representation

Configuration

Spring Boot

With Spring Boot, simply register the module as a bean:

kotlin
@Configuration
class JacksonConfig {

    @Bean
    fun typedValueModule(): Module = TypedValueModule()
}

The module is auto-detected by Spring Boot's Jackson auto-configuration.

Manual Configuration

For non-Spring applications:

kotlin
import com.fasterxml.jackson.module.kotlin.kotlinModule

val objectMapper = ObjectMapper()
    .registerModule(TypedValueModule())
    .registerModule(kotlinModule())

// Or using builder
val objectMapper = jsonMapper {
    addModule(kotlinModule())
    addModule(TypedValueModule())
}

Serialization

TypedValue is serialized as its raw value:

kotlin
data class UserDto(
    val id: TypedString<User>,
    val name: String
)

val user = UserDto(
    id = "user-123".toTypedString(),
    name = "Alice"
)

val json = objectMapper.writeValueAsString(user)
// {"id":"user-123","name":"Alice"}

Serialization Examples

kotlin
// String ID
val userId = "u-123".toTypedString<User>()
objectMapper.writeValueAsString(userId)  // "u-123"

// Long ID
val productId = 42L.toTypedLong<Product>()
objectMapper.writeValueAsString(productId)  // 42

// UUID ID
val orderId = UUID.randomUUID().toTypedUuid<Order>()
objectMapper.writeValueAsString(orderId)  // "550e8400-e29b-41d4-a716-446655440000"

Deserialization

Jackson resolves the entity type from the property's generic type:

kotlin
data class OrderDto(
    val id: TypedUuid<Order>,
    val userId: TypedString<User>,
    val productIds: List<TypedLong<Product>>
)

val json = """
{
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "userId": "user-123",
    "productIds": [1, 2, 3]
}
"""

val order = objectMapper.readValue<OrderDto>(json)
// order.id is TypedUuid<Order>
// order.userId is TypedString<User>
// order.productIds is List<TypedLong<Product>>

Type Resolution

The deserializer uses Jackson's type system to resolve generic parameters:

kotlin
// Type information comes from:
// 1. Property declaration
// 2. Generic type parameters
// 3. Contextual type from parent

data class Dto(
    val userId: TypedString<User>,    // Resolved as TypedString<User>
    val productId: TypedLong<Product> // Resolved as TypedLong<Product>
)

Custom TypedValue Types

If you create custom TypedValue subtypes, you must register them with the module to ensure proper runtime type reconstruction. Without registration, deserialization will fail or produce incorrect runtime types.

Why Registration is Required

When deserializing JSON, Jackson needs to know how to construct your custom type. Without registration:

  • Jackson creates instances of the parent type (e.g., TypedString) instead of your custom type
  • Attempting to cast the result to your custom type fails with ClassCastException

With registration:

kotlin
val myId: MyId<User> = objectMapper.readValue(json)
myId::class == MyId::class  // ✅ true - actual MyId instance

Without registration:

kotlin
val myId: MyId<User> = objectMapper.readValue(json)
// ❌ ClassCastException: TypedString cannot be cast to MyId

Registration Methods

The module provides three registration methods for different use cases:

kotlin
class MyId<T : Any>(id: String, type: KClass<T>) : TypedString<T>(id, type)

@Configuration
class JacksonConfig {
    @Bean
    fun typedValueModule() = TypedValueModule().apply {
        registerCustomTypedValue<MyId<*>, String> { value, entityKClass ->
            MyId(value, entityKClass)
        }
    }
}

Java-style with Class Parameters

kotlin
@Bean
fun typedValueModule() = TypedValueModule().apply {
    registerCustomTypedValue(
        typedValueClass = MyId::class.java,
        valueType = String::class
    ) { value, entityKClass ->
        MyId(value, entityKClass)
    }
}

Java Interop

java
public class MyId<T> extends TypedString<T> {
    public MyId(String id, KClass<T> type) {
        super(id, type);
    }
}

@Configuration
class JacksonConfig {
    @Bean
    public TypedValueModule typedValueModule() {
        TypedValueModule module = new TypedValueModule();
        module.registerCustomTypedValue(
            MyId.class,
            String.class,
            (value, entityClass) -> new MyId<>(
                value,
                JvmClassMappingKt.getKotlinClass(entityClass)
            )
        );
        return module;
    }
}

Usage Example

Once registered, your custom types work seamlessly:

kotlin
// DTOs with custom types
data class UserDto(
    val id: MyId<User>,
    val friendIds: List<MyId<User>>
)

// Serialization
val user = UserDto(
    id = MyId("user-123", User::class),
    friendIds = listOf(
        MyId("user-456", User::class),
        MyId("user-789", User::class)
    )
)

val json = objectMapper.writeValueAsString(user)
// {"id":"user-123","friendIds":["user-456","user-789"]}

// Deserialization
val deserialized = objectMapper.readValue<UserDto>(json)
deserialized.id::class == MyId::class  // ✅ true

Custom Validation Example

Registration is especially useful for types with validation logic:

kotlin
class ValidatedId<T : Any>(id: String, type: KClass<T>) : TypedString<T>(id, type) {
    init {
        require(id.matches(Regex("^[A-Z]{3}-\\d{6}$"))) {
            "Invalid ID format: $id (expected: XXX-123456)"
        }
    }
}

@Bean
fun typedValueModule() = TypedValueModule().apply {
    registerCustomTypedValue<ValidatedId<*>, String> { value, entityKClass ->
        ValidatedId(value, entityKClass)  // Triggers validation
    }
}

// Usage
val json = """{"id":"ABC-123456"}"""
val dto = objectMapper.readValue<UserDto>(json)  // ✅ Passes validation

val invalidJson = """{"id":"invalid"}"""
objectMapper.readValue<UserDto>(invalidJson)  // ❌ IllegalArgumentException

Important Notes

Registration Required

Built-in types (TypedValue, TypedString, TypedInt, TypedLong, TypedUuid) cannot be overridden. Attempting to register them throws IllegalArgumentException.

Registration Timing

Registration must happen before the module is registered with ObjectMapper. Jackson's ObjectMapper is immutable after construction, so all configuration must be done during setup.

Collections Support

Registered custom types work in collections (List, Set) just like built-in types:

kotlin
data class TeamDto(val memberIds: List<MyId<User>>)

Collections

TypedValue works in collections:

kotlin
data class TeamDto(
    val memberIds: List<TypedString<User>>,
    val projectIds: Set<TypedLong<Project>>
)

val json = """
{
    "memberIds": ["u-1", "u-2", "u-3"],
    "projectIds": [100, 200, 300]
}
"""

val team = objectMapper.readValue<TeamDto>(json)

Nullable Fields

Nullable TypedValue fields are supported:

kotlin
data class UserDto(
    val id: TypedString<User>,
    val managerId: TypedString<User>?  // Can be null
)

val json = """{"id": "u-1", "managerId": null}"""
val user = objectMapper.readValue<UserDto>(json)
// user.managerId is null

Error Handling

Invalid JSON values throw descriptive exceptions:

kotlin
// Invalid UUID format
val json = """{"id": "not-a-uuid"}"""

try {
    objectMapper.readValue<TypedUuid<Order>>(json)
} catch (e: JsonMappingException) {
    // "Invalid UUID string: not-a-uuid"
}

Limitations

Type Parameters Required

The deserializer requires explicit generic type parameters. Wildcards or raw types won't work:

kotlin
// OK - explicit type
data class GoodDto(val id: TypedString<User>)

// NOT OK - wildcard type
data class BadDto(val id: TypedString<*>)  // Cannot resolve entity type

Type Information Not Serialized

The entity type (T) is NOT included in the serialized JSON. It's resolved from the property declaration during deserialization.


Complete Example

Spring Boot REST Controller

kotlin
@Configuration
class JacksonConfiguration {
    @Bean
    fun typedValueModule() = TypedValueModule()
}

@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {

    @GetMapping("/{id}")
    fun getUser(@PathVariable id: TypedString<User>): UserDto {
        return userService.findById(id)
    }

    @PostMapping
    fun createUser(@RequestBody dto: CreateUserDto): UserDto {
        return userService.create(dto)
    }
}

data class CreateUserDto(
    val name: String,
    val email: String
)

data class UserDto(
    val id: TypedString<User>,
    val name: String,
    val email: String
)

API Summary

ClassPurpose
TypedValueModuleJackson module for registration
TypedValueSerializerSerializes TypedValue to raw ID
TypedValueDeserializerDeserializes JSON to TypedValue with contextual type resolution

Next Steps

Released under the MIT License.