
Generates type-safe discriminated unions via annotations from an interface spec, enforcing compile-time exhaustiveness; supports generics, union composition, sealed-class helpers, and JSON serialization.
Kotlin doesn't have union types like TypeScript's Int | String. unionKt gives you one.
Tell it which types a value can be, and it writes a type-safe union for you. Kotlin then makes sure you handle every case: forget one and your code won't compile.
Works everywhere Kotlin does: 🤖 Android · ☕ JVM · 🍎 iOS · 💻 macOS · 🌐 Web · 🐧 Linux · 🪟 Windows
@Union(Int::class, String::class, User::class)
interface ResultSpec
val result = Result(5)
val text = when (result) {
is Result.OnInt -> "number ${result.value}"
is Result.OnString -> "text ${result.value}"
is Result.OnUser -> "user ${result.value.name}"
}unionKt is on Maven Central. In your module's build.gradle.kts:
plugins {
id("com.google.devtools.ksp") version "2.3.12"
}
dependencies {
implementation("io.github.fcat97.unionkt:annotations:0.5.0")
ksp("io.github.fcat97.unionkt:processor:0.5.0")
}Tested with Kotlin 2.4.20 and KSP 2.3.12. On Android with AGP 9, use KSP 2.3.4 or newer.
Sharing code between Android, iOS, desktop and web? unionKt fits right in. Write your unions
once in commonMain and use the same types on every platform. No expect/actual, no copies
per platform.
// commonMain
@Union(User::class, NetworkError::class, Loading::class)
interface ScreenStateSpec
fun describe(state: ScreenState): String = state.fold(
onUser = { "Hello ${it.name}" },
onNetworkError = { "Something went wrong" },
onLoading = { "Loading…" },
)Android, iOS and web now all get the same ScreenState, with the same fold, the same
compile-time checks and the same JSON support. Everything in this README works in common
code: unions, generics, combining unions, @Derive and kotlinx.serialization.
Supported platforms:
| 🤖 Android | ☕ JVM (desktop and server) |
| 🍎 iOS | 💻 macOS |
| ⌚ watchOS | 📺 tvOS |
| 🌐 JavaScript | 🧩 WebAssembly |
| 🐧 Linux | 🪟 Windows |
| 📱 Android Native |
Add the annotations to commonMain, and the processor for each of your targets:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.fcat97.unionkt:annotations:0.5.0")
}
}
}
dependencies {
listOf("kspAndroid", "kspIosArm64", "kspIosSimulatorArm64", "kspJs").forEach {
add(it, "io.github.fcat97.unionkt:processor:0.5.0")
}
}Pick the names that match your targets, such as kspJvm, kspWasmJs or kspLinuxX64.
Write an interface whose name ends in Spec and list your types. unionKt creates the union
with the same name, minus Spec:
@Union(Int::class, String::class, User::class)
interface ResultSpec // you get: ResultEach type becomes a case called On<Type>, and the value inside it is value.
Create one. Just call the union's name with a value:
val a = Result(5)
val b = Result("hello")
val c = Result(User("Ada"))Handle every case with when, or with fold if you prefer one expression:
val text = result.fold(
onInt = { "number $it" },
onString = { "text $it" },
onUser = { "user ${it.name}" },
)Check or pull out one case:
result.isInt // true or false
result.intOrNull // the Int, or null if it's something elseUse type parameters when the types should be picked later. The classic example is "success or error":
@Union
interface EitherSpec<L, R>
fun parseAge(text: String): Either<String, Int> =
text.toIntOrNull()?.let { Either.onR(it) } ?: Either.onL("not a number")
parseAge("42").fold(onL = { "error: $it" }, onR = { "age $it" })You can mix fixed types and type parameters too: @Union(String::class) interface ParsedSpec<T>.
Put one union's Spec inside another and its types are added directly:
@Union(Circle::class, Square::class)
interface ShapeSpec
@Union(Int::class, ShapeSpec::class)
interface ItemSpec // Item can be an Int, a Circle or a Square
val item: Item = shape.toItem() // turn a Shape into an ItemIf you want the whole union as one case instead, use the union itself: Shape::class.
Already have a sealed class or interface? Add @Derive and you get fold and the same
helpers, without writing a Spec:
@Derive
sealed interface UiState {
data object Loading : UiState
data class Success(val items: List<Item>) : UiState
data class Error(val message: String) : UiState
}
val text = state.fold(
onLoading = { "Loading…" },
onSuccess = { "${it.items.size} items" },
onError = { it.message },
)
state.isLoading
state.successOrNull?.itemsIt works with generic sealed types like Result<T> too. If a case is itself a sealed type,
fold treats it as one case. Add @Derive to it as well to get its own fold.
Want to read and write unions as JSON? Add one more line, next to the usual kotlinx.serialization setup:
plugins {
kotlin("plugin.serialization") version "2.4.20"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
ksp("io.github.fcat97.unionkt:serialization-kotlinx:0.5.0")
}Now every union works with Json, with no extra wrapper in the JSON:
Json.encodeToString<Result>(Result(5)) // 5
Json.encodeToString<Result>(Result("hi")) // "hi"
Json.decodeFromString<Result>("\"hi\"") // Result.OnString("hi")Unions also work as properties inside your own @Serializable classes.
When reading JSON, unionKt tries your types in the order you listed them and uses the first one that fits. So if two of your classes look alike, list the more specific one first.
Every type in a serializable union must be @Serializable (basic Kotlin types and enums
already are). You need kotlinx-serialization-json 1.6.3 or newer. In a multiplatform project,
add serialization-kotlinx to the same KSP configurations as the processor.
Kotlin doesn't have union types like TypeScript's Int | String. unionKt gives you one.
Tell it which types a value can be, and it writes a type-safe union for you. Kotlin then makes sure you handle every case: forget one and your code won't compile.
Works everywhere Kotlin does: 🤖 Android · ☕ JVM · 🍎 iOS · 💻 macOS · 🌐 Web · 🐧 Linux · 🪟 Windows
@Union(Int::class, String::class, User::class)
interface ResultSpec
val result = Result(5)
val text = when (result) {
is Result.OnInt -> "number ${result.value}"
is Result.OnString -> "text ${result.value}"
is Result.OnUser -> "user ${result.value.name}"
}unionKt is on Maven Central. In your module's build.gradle.kts:
plugins {
id("com.google.devtools.ksp") version "2.3.12"
}
dependencies {
implementation("io.github.fcat97.unionkt:annotations:0.5.0")
ksp("io.github.fcat97.unionkt:processor:0.5.0")
}Tested with Kotlin 2.4.20 and KSP 2.3.12. On Android with AGP 9, use KSP 2.3.4 or newer.
Sharing code between Android, iOS, desktop and web? unionKt fits right in. Write your unions
once in commonMain and use the same types on every platform. No expect/actual, no copies
per platform.
// commonMain
@Union(User::class, NetworkError::class, Loading::class)
interface ScreenStateSpec
fun describe(state: ScreenState): String = state.fold(
onUser = { "Hello ${it.name}" },
onNetworkError = { "Something went wrong" },
onLoading = { "Loading…" },
)Android, iOS and web now all get the same ScreenState, with the same fold, the same
compile-time checks and the same JSON support. Everything in this README works in common
code: unions, generics, combining unions, @Derive and kotlinx.serialization.
Supported platforms:
| 🤖 Android | ☕ JVM (desktop and server) |
| 🍎 iOS | 💻 macOS |
| ⌚ watchOS | 📺 tvOS |
| 🌐 JavaScript | 🧩 WebAssembly |
| 🐧 Linux | 🪟 Windows |
| 📱 Android Native |
Add the annotations to commonMain, and the processor for each of your targets:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.fcat97.unionkt:annotations:0.5.0")
}
}
}
dependencies {
listOf("kspAndroid", "kspIosArm64", "kspIosSimulatorArm64", "kspJs").forEach {
add(it, "io.github.fcat97.unionkt:processor:0.5.0")
}
}Pick the names that match your targets, such as kspJvm, kspWasmJs or kspLinuxX64.
Write an interface whose name ends in Spec and list your types. unionKt creates the union
with the same name, minus Spec:
@Union(Int::class, String::class, User::class)
interface ResultSpec // you get: ResultEach type becomes a case called On<Type>, and the value inside it is value.
Create one. Just call the union's name with a value:
val a = Result(5)
val b = Result("hello")
val c = Result(User("Ada"))Handle every case with when, or with fold if you prefer one expression:
val text = result.fold(
onInt = { "number $it" },
onString = { "text $it" },
onUser = { "user ${it.name}" },
)Check or pull out one case:
result.isInt // true or false
result.intOrNull // the Int, or null if it's something elseUse type parameters when the types should be picked later. The classic example is "success or error":
@Union
interface EitherSpec<L, R>
fun parseAge(text: String): Either<String, Int> =
text.toIntOrNull()?.let { Either.onR(it) } ?: Either.onL("not a number")
parseAge("42").fold(onL = { "error: $it" }, onR = { "age $it" })You can mix fixed types and type parameters too: @Union(String::class) interface ParsedSpec<T>.
Put one union's Spec inside another and its types are added directly:
@Union(Circle::class, Square::class)
interface ShapeSpec
@Union(Int::class, ShapeSpec::class)
interface ItemSpec // Item can be an Int, a Circle or a Square
val item: Item = shape.toItem() // turn a Shape into an ItemIf you want the whole union as one case instead, use the union itself: Shape::class.
Already have a sealed class or interface? Add @Derive and you get fold and the same
helpers, without writing a Spec:
@Derive
sealed interface UiState {
data object Loading : UiState
data class Success(val items: List<Item>) : UiState
data class Error(val message: String) : UiState
}
val text = state.fold(
onLoading = { "Loading…" },
onSuccess = { "${it.items.size} items" },
onError = { it.message },
)
state.isLoading
state.successOrNull?.itemsIt works with generic sealed types like Result<T> too. If a case is itself a sealed type,
fold treats it as one case. Add @Derive to it as well to get its own fold.
Want to read and write unions as JSON? Add one more line, next to the usual kotlinx.serialization setup:
plugins {
kotlin("plugin.serialization") version "2.4.20"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
ksp("io.github.fcat97.unionkt:serialization-kotlinx:0.5.0")
}Now every union works with Json, with no extra wrapper in the JSON:
Json.encodeToString<Result>(Result(5)) // 5
Json.encodeToString<Result>(Result("hi")) // "hi"
Json.decodeFromString<Result>("\"hi\"") // Result.OnString("hi")Unions also work as properties inside your own @Serializable classes.
When reading JSON, unionKt tries your types in the order you listed them and uses the first one that fits. So if two of your classes look alike, list the more specific one first.
Every type in a serializable union must be @Serializable (basic Kotlin types and enums
already are). You need kotlinx-serialization-json 1.6.3 or newer. In a multiplatform project,
add serialization-kotlinx to the same KSP configurations as the processor.