One thing which I found pretty useful in terms of type safety is to create special wrappers for primitive types.

Idea is following: assume that in code we have 2 entities: User and Group. Both can be represented as simple strings:

data class SomeAggregation(
    val user: String,
    val group: String
)

Simple solution. But problem can happen when we accidentally misuse that values.

fun validateGroupInDenylist(group: String) ...

...

validateGroupInDenylist(aggregation.user)

In most such cases Unit Testing can help find such mistakes. But there still some chance that this bug will sneak into production code.

Alternative could be to create simple dedicated type.

data class User(val value: String)
data class Group(val value: String)

data class SomeAggregation(
    val user: User,
    val group: Group
)

fun validateGroupInDenylist(group: Group) ...

Now if we try to misuse argument validateGroupInDenylist(aggregation.user) compiler instantly notify us about this.

Simple technics, but it actually saved me from hours of debugging.

Json Serialization

One caveat here could be JSON serialization.

By default json representation of SomeAggregation will look a bit strange:

{
    "user": {"value": "userValue"}
    "group": {"value": "groupValue"}
}

While it is possible to create individual serializer for each type - it instantly become cumbersome. Different solution could be in defining only base type.

Base type for wrapper


import com.fasterxml.jackson.annotation.JsonIgnore
import java.io.Serializable

abstract class StringWrapper(val value: String) : Serializable {

    override fun toString() = value

    override fun equals(other: Any?): Boolean {
        if (other == null) {
            return false
        }
        if (this::class != other::class) {
            return false
        }
        return this.value == (other as StringWrapper).value
    }

    override fun hashCode(): Int = value.hashCode()
}

Later we can use it as usual:

class User(value: String) : StringWrapper(value)

Object mapper

In this case we need to teach serializer to work with that wrapper only once:


/**
 * Allow jackson to properly select deserializer for StringWrapper subclass
 */
class StringWrapperModuleDeserializers : SimpleDeserializers() {
    override fun findBeanDeserializer(
        type: JavaType,
        config: DeserializationConfig,
        beanDesc: BeanDescription
    ): JsonDeserializer<*>? {
        if (type.isTypeOrSubTypeOf(StringWrapper::class.java)) {
            return StringWrapperDeserializer(type)
        }
        return super.findBeanDeserializer(type, config, beanDesc)
    }
}

/**
 * Deserialize subclass of StringWrapper from a simple string value.
 */
class StringWrapperDeserializer(private val type: JavaType) : JsonDeserializer<StringWrapper>() {
    override fun deserialize(parser: JsonParser, p1: DeserializationContext?): StringWrapper? {
        return type.rawClass.getConstructor(String::class.java).newInstance(parser.valueAsString.intern()) as StringWrapper
    }
}

/**
 * Serializes StringWrapper as a simple string.
 */
class StringWrapperSerializer : JsonSerializer<StringWrapper>() {
    override fun serialize(value: StringWrapper?, jgen: JsonGenerator, p2: SerializerProvider?) {
        if (value == null) {
            jgen.writeString("")
        } else {
            jgen.writeString(value.value)
        }
    }
}

/**
 * Serializes StringWrapper as a string map key.
 */
class StringWrapperKeySerializer : JsonSerializer<StringWrapper>() {
    override fun serialize(value: StringWrapper?, jgen: JsonGenerator, p2: SerializerProvider?) {
        if (value == null) {
            jgen.writeFieldName("")
        } else {
            jgen.writeFieldName(value.value)
        }
    }
}

fun register(): ObjectMapper {
    val simpleModule = SimpleModule()
    simpleModule.setDeserializers(StringWrapperModuleDeserializers())
    simpleModule.addKeySerializer(StringWrapper::class.java, StringWrapperKeySerializer())
    simpleModule.addSerializer(StringWrapper::class.java, StringWrapperSerializer())

    return jacksonObjectMapper()
        .registerModule(simpleModule)
}

Now it is possible to use wrapped types as any other primitive type

data class Data(
    val users: Map<Group, List<User>>
)

Will be nicely serialized as:

{
    "group1": ["user1", "user2"]
}