sealed class(interface)をenum的に扱えるようにするKotlin Compiler Plugin、sealed-class-enumizerを公開しました(スターください!)。
この記事では、このプラグインの機能と嬉しさを紹介します。
従来のsealed classの課題
sealed classは、しばしば強化版enumのように扱われます。
一方、実際にenumのように扱いたいと思った時、以下のような問題に当たります。
-
name的なプロパティは自動生成されない - 非
objectの場合、利用にはインスタンスが必要- インスタンスが手に入らないスコープでは、ダミー値によるインスタンス化や、代替
enumを別途定義するなど、任意の工夫が必要
- インスタンスが手に入らないスコープでは、ダミー値によるインスタンス化や、代替
- 末端を列挙したい場合、
kotlin-reflectが必要
sealed-class-enumizerの機能と嬉しさ
sealed-class-enumizerは、sealed classをenum的に扱うための諸機能を生成する、Kotlin Multiplatform対応のKotlin Compiler Pluginです。
これを使うことで、画像のような生成と補完1を利用できます。
なお、生成されるAPIは通常の宣言としてメタデータに載るため、ライブラリの利用側モジュールにはプラグインの適用が不要です。
末端の「種類」を表すEnumishが生成される
@Enumizeを付けると、末端21種類につき1つのシングルトンがEnumish(enum定数相当の「種類」を表す型)として生成されます。
末端がclassの場合はそのcompanion objectが、末端がobjectの場合はそれ自身がEnumishになります。
これに伴い、sealed class本体と個々の末端へAPIが生成されます。
- 末端の
Enumish(${class}.Companionまたはobject自身)-
label:Enum::name相当。既定値は末端の単純名 -
enumizedClass:Enumishに対応する末端のKClass
-
- 末端
-
asEnumish(): 値からEnumishを得る -
label:Enumishのラベル(拡張関数経由のため、末端自身がlabelを持つ場合シャドウされる)
-
-
${sealed class}.Enumish.Companion-
entries:Enum::entries相当。末端を列挙する -
valueOf:Enum::valueOf相当。labelからEnumishを引く -
valueOfOrNull:nullable版valueOf
-
これらは全てコンパイル時に生成され、リフレクション無しで利用できます。
import io.github.projectmapk.sealedClassEnumizer.Enumize
import io.github.projectmapk.sealedClassEnumizer.label
@Enumize
sealed interface Status {
data class Active(val remarks: String) : Status
data class Suspended(val remarks: String) : Status
data object Deleted : Status
}
// 末端の列挙と、label からの逆引き
Status.Enumish.entries // [Active, Deleted, Suspended]
Status.Enumish.entries.map { it.label } // ["Active", "Deleted", "Suspended"]
Status.Enumish.valueOf("Active") // Status.Active
Status.Enumish.valueOfOrNull("Unknown") // null
// 値から Enumish へ
val status: Status = Status.Active(remarks = "")
status.asEnumish() // Status.Active(= Active.Companion)
status.label // "Active"
status.asEnumish().enumizedClass // Status.Active::class
// 生成される Enumish は sealed なので、else 無しの網羅 when が書ける
when (status.asEnumish()) {
Status.Active -> TODO()
Status.Suspended -> TODO()
Status.Deleted -> TODO()
}
使い方の例
name的な固有名
labelにより、enumのnameと同じ感覚で「どの末端か」を文字列として取り出せます。
逆向きの変換もvalueOfで行えるため、DBのカラムやクエリパラメータとの相互変換を手書きのマッピング無しに書けます。
// 送出: label がそのままワイヤ表現になる
fun statusQuery(selection: Set<Status.Enumish>): String =
selection.joinToString("&") { "status=${it.label}" }
// 受信: GET /foos?status=Active&status=Deleted
fun parse(rawStatuses: List<String>): List<Status.Enumish> = rawStatuses.map {
requireNotNull(Status.Enumish.valueOfOrNull(it)) { "unknown status: $it" }
}
labelの既定値は末端の単純名ですが、@EnumishLabel("Active")のような明示指定や、@Enumize(labelCase = LabelCase.UPPER_SNAKE_CASE)によるケース変換も利用できます。
ケース変換はプロジェクト単位でデフォルト値を指定することもできます。
インスタンス無しで「どの末端か」を扱える
生成されるEnumishは常に存在するシングルトンなので、enumを書く場所にそのまま書けます。
class Foo(val status: Status)
interface FooRepository {
fun searchFoo(vararg statuses: Status.Enumish): List<Foo>
}
class InMemoryFooRepository(private val foos: List<Foo>) : FooRepository {
override fun searchFoo(vararg statuses: Status.Enumish): List<Foo> =
foos.filter { it.status.asEnumish() in statuses }
}
// data class の Active も data object の Deleted も、同じように書ける
repository.searchFoo(Status.Active, Status.Deleted)
これは以前の記事で紹介した「各末端にcompanion objectを書き、共通のmarker interfaceを実装させる」という手作業の自動化にあたります。
kotlin-reflect無しで末端を列挙できる
sealed classの末端の列挙には従来KClass.sealedSubclassesが必要でしたが、これはJVM専用な上、kotlin-reflectが必要でした。
entriesはコンパイル時に生成されるため、これらの制約無しに「全ての末端」を得られます。
// 選択肢の生成: 末端を追加してもこのコードは触らなくて良い
val statusOptions: List<String> = Status.Enumish.entries.map { it.label }
whenで書けない末端毎の紐付けの網羅性検査にも使えます。
DIで組み立てられるハンドラのMapのように、実体がデータ側に有るケースはコンパイラが完全性を検査できませんが、entriesが有れば1つのassertionで済みます。
class StatusRenderer(private val cells: Map<Status.Enumish, CellRenderer>) {
init {
val missing = Status.Enumish.entries - cells.keys
require(missing.isEmpty()) { "statuses without a renderer: ${missing.map { it.label }}" }
}
}
entriesは、値の網羅的テストにも役立ちます。
また、enumizedClassが得られるため、リフレクションを用いたテスト用インスタンス生成のような使い方にも繋げられます。
// プロダクションコード: Enumish 毎のファクトリ(フォームの初期値・テストフィクスチャ等)
fun defaultStatusOf(kind: Status.Enumish): Status = when (kind) {
Status.Active -> Status.Active(remarks = "")
Status.Suspended -> Status.Suspended(remarks = "payment failed")
Status.Deleted -> Status.Deleted
}
class DefaultStatusTest {
@Test
fun `every status yields a default of its own type`() {
// enumizedClass により、Enumish 経由で得た値の型まで検査できる
for (kind in Status.Enumish.entries) {
assertEquals(kind.enumizedClass, defaultStatusOf(kind)::class)
}
}
}
sealed classの自由度はそのまま
@Enumizeを付けても、sealed class本来の書き方に制約は増えません。
一部仕様上の制約は有れど、基本的に生成結果が自由な記述を制約しないよう配慮したAPIとなっています。
末端が非final(open / abstract classやinterface)の場合、階層外で定義されたサブタイプはその末端のEnumishに吸収され、entriesは変動しません。
つまり、分類の粒度は固定したまま、各分類の実装は開いておくことができます。
これはenumには無い自由度です。
@Enumize
sealed interface Shape {
data class Circle(val r: Double) : Shape // 末端(final)
abstract class Polygon : Shape // 末端(拡張点として開いておく)
}
// 別モジュールで実装しても、entries は増えない
class Triangle : Shape.Polygon()
Shape.Enumish.entries // [Circle, Polygon]
Triangle().asEnumish() // Shape.Polygon
利用方法
Gradle
Gradle Plugin Portalで公開しており、plugins {}にそのまま書くだけで機能全てを利用できます。
plugins {
kotlin("jvm") version "2.4.10" // kotlin("multiplatform") でも良い
id("io.github.projectmapk.sealed-class-enumizer") version "2.4.10-0.1.0"
}
Maven Pluginに関しては現在公開しておりませんが、実装は存在しているため、伸びるようなら公開するつもりです。
IntelliJ IDEAの設定
IntelliJ IDEAは、既定ではサードパーティのコンパイラプラグインを読み込みません(KTIJ-29248)。
生成APIをエディタ上でも解決させるには、Registry(Help | Find Action…からRegistry…)でkotlin.k2.only.bundled.compiler.plugins.enabledのチェックを外し、プロジェクトを再同期します。
この機能はIntelliJ側で実験的な扱いですが、有効にすると生成された宣言が補完・解決の対象になります。
まとめ
sealed-class-enumizerは、sealed classの表現力を保ったままenumの操作系APIを後付けするコンパイラプラグインです。
リフレクション非依存かつKotlin Multiplatform対応であるため、様々な環境で利用できます。
「sealed classをenumのように使いたいが、そのための手作業が面倒」と感じたことが有る方は、ぜひ試してみてください。
スターを頂けると嬉しいです!

