I have written before about Kotlin's Duration class, and its blend of technical sophistication and developer friendliness continues to impress. Recently, a unit test highlighted an important distinction in how Kotlin handles duration parsing. The duration string "1s" works perfectly with Duration.parse(), but fails during JSON deserialization when dealing with real-world data.
This apparent inconsistency is actually a deliberate design choice. The Duration.parse() method is flexible by design, while kotlinx.serialization is intentionally strict to enforce standardization.
What happens under the hood?
Examining Kotlin's source code clarifies this behavior. First, let's look at the Duration class itself:
// In Duration.kt
public inline class Duration internal constructor(
private val rawValue: Long
) : Comparable {
// ...
companion object {
public fun parse(value: String): Duration = try {
parseDuration(value, strictIso = false)
} catch (e: IllegalArgumentException) {
throw IllegalArgumentException(
"Invalid duration string format: '$value'.", e
)
}
}
}
A few key points stand out in this implementation:
-
Durationis an inline class with an internal constructor. - This constructor cannot be used for deserialization due to its internal visibility.
- The
parse()method intentionally accepts non-ISO formats by explicitly settingstrictIso = false.
Now, let's look at how kotlinx.serialization handles the Duration class:
internal object DurationSerializer : KSerializer {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("kotlin.time.Duration", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Duration) {
encoder.encodeString(value.toIsoString())
}
override fun deserialize(decoder: Decoder): Duration {
return Duration.parseIsoString(decoder.decodeString())
}
}
The serializer makes a deliberate choice to use parseIsoString() for deserialization. Instead of relying on the more permissive parse() method, it enforces the ISO-8601 standard for data integrity.
Implementing a lenient custom serializer
Since we cannot change the built-in behavior of kotlinx.serialization—and generally shouldn't, given the importance of enforcing standards in data exchange—we can create our own custom lenient serializer.
internal object LenientDurationSerializer : KSerializer {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("kotlin.time.Duration", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): Duration {
return Duration.parse(decoder.decodeString()) // Using the lenient parser
}
override fun serialize(encoder: Encoder, value: Duration) {
encoder.encodeString(value.toIsoString()) // Still formal in output using ISO
}
}
This serializer accepts casual input formats by using parse(), but ensures formal ISO-8601 formatting on output by using toIsoString(). You can apply it to your data classes using the @Serializable annotation:
data class ProcessDuration(
@Serializable(with = LenientDurationSerializer::class)
val duration: Duration
)
// Now both these work:
val formal = """{"duration": "PT1S"}"""
val casual = """{"duration": "1s"}"""
This approach adheres to a fundamental principle of data exchange: be flexible in what you accept, but strict in what you produce. Watch how our duration gets standardized during serialization:
// What goes in
{"duration": "1s"}
// What comes out
{"duration": "PT1S"}
Both strings represent the exact same duration, but the output is standardized. When writing data that might be consumed by other systems, enforcing an ISO format is a necessary practice for maintaining a reliable ecosystem.
Standards vs. reality
While enforcing ISO-8601 in serialization is technically correct, real-world data is often much less organized. Anyone who has dealt with dates, times, and durations in production environments knows the variety of formats that can appear.
In a typical production environment, you might encounter:
- Natural language timestamps generated by NLP parsing services (e.g., "Last Monday").
- Inconsistent timezones represented in various ways such as "GMT+1", "UTC+1", "+0100", or "Europe/Paris".
- Varying date formats originating from legacy systems (e.g., "23-11-2023", "11-23-2023", or "2023/11/23").
- Raw millisecond values typically found in Java integrations ("5400000ms").
- Excessively verbose XML-style durations from older architectures ("P0Y0M0DT1H30M0S").
The variety of time formats is a persistent challenge in software development. While standards like ISO-8601 are crucial for reliable data exchange, being overly rigid can cause integrations to fail. A pragmatic approach requires us to handle the data as it arrives, gracefully converting it into a standardized format for the future.
Conclusion
Dealing with rigid data formats can sometimes feel like P100Y of ISO-litude1: an endless century of strict parsing errors and isolated systems. However, examining Kotlin's source code reveals the thoughtful design behind its time packages.
By allowing Duration.parse() to welcome various formats for developer convenience, while keeping kotlinx.serialization anchored to ISO standards, the ecosystem strikes a perfect balance. You do not have to be isolated by strict formats; you simply need a custom serializer to bridge the gap between human-friendly inputs and machine-standardized outputs.
-
For the uninitiated: P100Y is the valid ISO-8601 duration string for exactly 100 years, with apologies to Gabriel García Márquez ;-) ↩
Top comments (1)
Hi Guys, I'm SamTech a Developer Talent Connector. We're currently recruiting experienced Web Developers, Frontend Developers, Backend Developers, Full-Stack Developers, and App Developers for long-term collaborations with an American development team. This is a remote opportunity involving real international projects, with payments available in USD or local currency. If you're interested in learning more, reply to this comment and I'll be happy to share the details.