🦔

Kotlin/JS で sealed interface の網羅性が消える問題を KSP Plugin で解決する

に公開

"誰が使うんこれ" なニッチな技術が好きなみなさん、こんにちは!👋 てべすてんです。

Github リポジトリ:

https://github.com/TBSten/kotlin-js-sealed-ksp-practice

🎯 最初に結論

  • ❌ Kotlin/JS では sealed interface/class の網羅性が失われる
  • ✅ KSP で TypeScript のパターンマッチング関数を自動生成して 網羅性を保つ
Kotlin
@JsExport
sealed interface MyScreenState

@JsExport
data object LoadingState : MyScreenState

@JsExport
data class SuccessState(
    val data: String,
) : MyScreenState

@JsExport
data class ErrorState(
    val error: Throwable,
) : MyScreenState
TypeScript からの呼び方
whenMyScreenState(LoadingState.getInstance(), {
  errorState: (error) => `Error: ${error}`,
  loadingState: (_loading) => "Loading...",
  successState: (success) => `Success: ${success}`
})

🚨 Kotlin/JS では sealed interface/class が綺麗に出力されない

執筆時点では sealed interface を用意しても JS で出力される際に ただの interface として出力されてしまいます。せっかく sealed interface にして網羅性を保証したのに JS からはこの網羅性が消えてしまいます。😢

https://youtrack.jetbrains.com/issue/KT-71798/KJS-Sealed-classes-should-be-compiled-to-union-types

例えば、以下のように MyScreenState を定義して JsExport したとしても MyScreenState のインスタンスを LoadingState, SuccessState, ErrorState の 3パターンにタイプセーフに分岐することはできません。

@JsExport
sealed interface MyScreenState

@JsExport
data object LoadingState : MyScreenState

@JsExport
data class SuccessState(val data: List<String>) : MyScreenState

@JsExport
data class ErrorState(val error: Throwable) : MyScreenState

@JsExport
fun getMyScreenState() = LoadingState
const state: MyScreenState = ...
if(state instanceof LoadingState) {
  // TODO
} else if(state instanceof SuccessState) {
  // TODO
} else if(state instanceof ErrorState) {
  // TODO
} else {
  // ❌ TypeScript 的にはここに到達可能と判断されてしまう (state: never にならない)
}

🔧 網羅する関数を作成する

この問題に対処するために、以下のような TypeScript の関数を sealed interface ごとに用意します。このような関数をこの記事では 「パターンマッチング関数」 と呼ぶことにします。

export function whenMyScreenState<const R>(
  myScreenState: MyScreenState,
  blocks: {
    errorState: (errorState: ErrorState) => R,
    loadingState: (loadingState: LoadingState) => R,
    successState: (successState: SuccessState) => R,
  },
) {
  if(myScreenState instanceof ErrorState) {
    return blocks.errorState(myScreenState)
  } else if(myScreenState instanceof LoadingState) {
    return blocks.loadingState(myScreenState)
  } else if(myScreenState instanceof SuccessState) {
    return blocks.successState(myScreenState)
  } else {
    // Kotlin から渡された MyScreenState を使っていれば
    // ここは到達しないはず
    throw new TypeError(
      `Invalid typeof myScreenState type.\n` +
      `MyScreenState must be ErrorState or LoadingState or SuccessState,\n` +
      `but none of the above`,
    )
  }
}

これは以下のように呼び出すことができます。 Kotlin のパターンマッチングみたいで書き心地よいと感じるのではないでしょうか。

const stateString: "⚪️" | "🟩" | "❌" =
  whenMyScreenState(state, {
    loadingState: () => "⚪️" as const,
    successState: () => "🟩" as const,
    errorState: () => "❌" as const,
  })

🤖 パターンマッチング関数を自動生成する

sealed interface ごとにパターンマッチング関数を用意するのは面倒なので、この TypeScript コードを KSP で自動生成しましょう。

Kotlin のコード生成技術として KSP があります。

普段は Kotlin コードの生成に使われがちな KSP ですが、実は 出力できるファイルの種類に制限はありません。つまり TypeScript のコードだって生成できます!🎉

今回は KSP を使って TypeScript のコードを自動生成してみたいと思います。

📋 実装方針

KSP Plugin を作る前に 実装方針 を整理しましょう。💡
以下の 5ステップで実装してみようと思います。

  1. プロジェクト / KSP Plugin をセットアップ
  2. @JsExport がついていて sealed な interface/class を見つける
  3. 1 で見つけた interface/class ごとに以下のような TypeScript ファイルを生成する。
import { ${CHILD_TYPE_NAMEjoinToString(", ") したもの} } from "${moduleName}"

export function when${SEALED_TYPE_NAME}<const R>(
  myScreenState: ${SEALED_TYPE_NAME},
  blocks: {
    // 子クラスごとに以下を繰り返す
    ${CHILD_VARIABLE_NAME}: (${CHILD_VARIABLE_NAME}: ${CHILD_TYPE_NAME}) => R,
  },
) {
  // 子クラスごとに分岐の if 文をを繰り返す
  if(${SEALED_VARIABLE_NAME} instanceof ${CHILD_TYPE_NAME}) {
    return blocks.errorState(myScreenState)
  } else {
    throw new TypeError(`Invalid typeof  type.`)
  }
}
プレースホルダーの意味
  • moduleName は JS のモジュール名 (KSP のオプションとして設定する)
  • SEALED~ は見つけた sealed interface
  • CHILD~ は sealed interface の各 子クラス
  • ~TYPE_NAME型名 として使う際の名前 (UpperCamelCase)
  • ~TYPE_NAME変数名 として使う際の名前 (lowerCamelCase)
  1. 作成した KSP Plugin を KMP プロジェクトに設定する
  2. .ts ファイルを JS プロジェクトから読み込む

要は sealed interface/class を見つけて、それぞれに対応する パターンマッチング関数 を生成していきます。

🏗️ Step1. プロジェクト / KSP Plugin をセットアップ

Step1-1. プロジェクトテンプレートからプロジェクト作成

Kotlin/JS で出力したコードを JS から使う際は Jetbrains 公式の Kotlin Multiplatform Wizard が手っ取り早くて便利です。✨

https://kmp.jetbrains.com/?desktop=true&web=true&webui=react&includeTests=true

Web を選択し、 Do not share UI - Use React with TypeScript を選択してください。あとは好みで大丈夫です。😊

入力例

入力したら DOWNLOAD ボタンからダウンロードしてプロジェクトを IntelliJ IDEA や Android Studio で開きます。📥

Step1-2. プロジェクトの構成と Kotlin/JS

テンプレートプロジェクトのディレクトリ構成はざっくり以下のようになっています。📂

  • shared ... 共通で使用するロジックが入っています。今回はこの中に sealed interface を配置して webApp から使用します。
  • webApp ... サンプルの React プロジェクトが入っています。
    • src ... TypeScript のコードは基本的にはこの中に配置します。

またプロジェクトには jsBrowserDevelopmentLibraryDistribution という Gradle Task がセットアップされています。このタスクを叩くと Kotlin のコードが JS コードにトランスパイルされ <module>/build/dist/js/developmentLibrary/ というディレクトリに配置されます。⚙️

./gradlew jsBrowserDevelopmentLibraryDistribution

# 正常に完了すると
#   <module>/build/dist/js/developmentLibrary
# に JS コードが出力される

🔧 Step2. KSP Plugin をセットアップする

KSP Plugin のセットアップは 公式の KSP quickstart が非常に参考になります。📚

https://kotlinlang.org/docs/ksp-quickstart.html#create-a-processor-of-your-own

基本的にはドキュメント通りですが、一応記事でも追っていこうと思います。

今回は ksp-plugin というモジュールを作りその中に KSP Plugin を実装していくことにします。
KSP Plugin セットアップのためにやることは 5つです。

  1. Kotlin/JVM のモジュールを作る
  2. モジュールの依存関係 (dependencies) に KSP の API を追加する。
  3. SymbolProcessorProvider および SymbolProcessor クラスを継承したクラスを作成し、自動生成のロジックを書く。
  4. 3 で作成した SymbolProcessorProvider クラスを KSP に登録する。
  5. KSP の自動生成をしたいモジュールの依存関係に 作成した KSP Plugin のモジュールを追加する。

Step2-1. Kotlin/JVM のモジュールを作る

KSP Plugin はなんの変哲もない Kotlin/JVM モジュールとして実装できます。
(試してないですが 多分 Multiplatform モジュールでもできるはず。JVM で動けば良いので。)

モジュール追加のコード
gradle/libs.versions.toml
...

[plugins]
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
settings.gradle.kts
...

include(":ksp-plugin")
shared/build.gradle.kts
...
plugins {
  alias(libs.plugins.kotlinJvm) apply false
}
build.gradle.kts
...
plugins {
  ...
  alias(libs.plugins.kotlinJvm)
}

Step2-2. モジュールの依存関係 (dependencies) に KSP の API を追加する。

KSP Plugin を実装するために必要な依存関係を入れます。

shared/build.gradle.kts
...
dependencies {
  implementation(libs.kspApi)
}
gradle/libs.versions.toml
...
[versions]
kotlin = "2.2.20"
ksp = "2.2.20-2.0.3"

[libraries]
...
kspApi = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }

KSP のバージョンは プロジェクトの kotlin バージョンにあるものを release note から探してください。

https://github.com/google/ksp/releases

このタイミングで IDE の Gradle Sync を行っておくと諸々の補完が効くように なります ✨

Step2-3. SymbolProcessorProvider および SymbolProcessor クラスを継承したクラスを作成し、自動生成のロジックを書く。

KSP に SymbolProcessorProviderSymbolProcessor が用意されています。それぞれ以下のような役割を持っています。📋

  • SymbolProcessorProvider ... SymbolProcessor を生成する。create メソッドをオーバーライドして SymbolProcessor を呼び出す。
  • SymbolProcessor ... 肝心の 生成ロジックを書く場所。process メソッドをオーバーライドして 生成ロジックを書く。

今回は それぞれ SealedPatternMatcherFunGeneratorProvider / SealedPatternMatcherFunGenerator という名前で作ってみようと思います。

ksp-plugin/src/main/kotlin/me/tbsten/prac/kotlinjssealedksp/SealedPatternMatcherFunGeneratorProvider.kt
class SealedPatternMatcherFunGeneratorProvider : SymbolProcessorProvider {
  override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
    SealedPatternMatcherFunGenerator(
      codeGenerator = environment.codeGenerator,
      options = environment.options,
    )
}

SealedPatternMatcherFunGeneratorProvider.create は 単に SealedPatternMatcherFunGenerator をインスタンス化しているだけです。😊

environment という引数に生成時に利用できるツールがいくつか入っています。今回は以下二つを使います。🔧

  • environment.codeGenerator はいい感じのディレクトリに コード生成してくれる CodeGenerator クラスのインスタンスが受け取れます。
  • environment.options は Gradle Plugin から ksp { arg(key, value) } を使ってオプションを設定でき、その option を受け取るためのプロパティです。Map<String, String>
ksp-plugin/src/main/kotlin/me/tbsten/prac/kotlinjssealedksp/SealedPatternMatcherFunGenerator.kt
class SealedPatternMatcherFunGenerator(
  private val codeGenerator: CodeGenerator,
  private val options: Map<String, String>,
) : SymbolProcessor {
  override fun process(resolver: Resolver): List<KSAnnotated> {
    TODO("Not yet implemented")
  }
}

コンストラクタで受け取った codeGenerator や options を使って SymbolProcessor クラスで実際の自動生成処理を書きます。
SealedPatternMatcherFunGenerator.process() は後ほど Step 3 で実装します。⏳

Step2-4. 3 で作成した SymbolProcessorProvider クラスを KSP に登録する。

ただクラスを定義しただけでは KSP が SymbolProcessorProvider を認識できません。😅
そこで JVM の Service Loader という機能を使って KSP に登録します。

Service Loader は <module>/src/main/resources/META-INF/services/ というディレクトリ内にテキストファイルを置くことで実行時に class をインスタンス化させることができる機能です。🔧

KSP の場合は <module>/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider というファイルに KSP に登録したいクラス名をテキストファイルとして登録します。

ksp-plugin/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
me.tbsten.prac.kotlinjssealedksp.SealedPatternMatcherFunGeneratorProvider

これで KSP が 独自クラスである SealedPatternMatcherFunGeneratorProvider を認識できるようになりました。✨

ここまでで最低限の KSP Plugin のセットアップは完了です。

Step2-5. KSP の自動生成をしたいモジュールの依存関係に 作成した KSP Plugin のモジュールを追加する。

最後に利用するモジュールで KSP と ksp-plugin モジュールを追加します。⚙️
今回は shared モジュールに KSP の Gradle Plugin と ksp-plugin モジュールを追加します。

shared/build.gradle.kts
plugins {
  // KSP の Gradle Plugin を設定
  alias(libs.plugins.ksp)
}

// kotlin.sourceSets.xxx.dependencies ではなく、トップレベルの dependencies に追加する
dependencies {
    add("kspJs", project(":ksp-plugin"))
}

1点 注意として、KMP プロジェクトで KSP Plugin を設定する場合は dependencies { add("ksp<platform>", ...) } を使ってプラットフォームごとに plugin の依存関係を定義する必要があります。

https://kotlinlang.org/docs/ksp-multiplatform.html#compilation-and-processing

ただ今回は JS 向けにしか使用しないので、"kspJs" のみ KSP Plugin が走るようにしています。

また ksp-plugin モジュールを利用したいモジュールが複数ある場合は、すべてのモジュールに設定する必要があります。必要に応じて composite build などで共通化してもいいかもしれませんね。

これで準備が整いました!次の Step からいよいよ 本題の自動生成部分を実装していきます。🚀

Step3. @JsExport がついていて sealed な interface/class を見つける

いよいよここからが本題です!
前述の通り SymbolProcessor (今回は SealedPatternMatcherFunGenerator) の process メソッドに生成処理を書いていきます。

何はともあれまずは sealed interface/class を見つけます。
process メソッドには Resolver という引数があり、これを使って対象モジュール内のクラスなどを検索することができます。

今回利用する Resolver の API を列挙します。

API 説明
resolver.getSymbolsWithAnnotation("アノテーション名") 特定のアノテーションがついた Symbol (クラスなどの定義) を Sequence として収集できます。
KSClassDeclaration class や interface, object などの定義のみに絞る場合は このクラスのインスタンスを探します。
simpleName クラス名を取得します。ネストされたクラスの場合は 親クラス名は含まれない点に注意です。 他にも qualifiedName (パッケージ名を含めたクラス名) などがあり適宜使い分ける必要があります。
modifiers 定義に設定された修飾子 (class などの直前につくもの) が Set として入っています。
getSealedSubclasses() そのクラスが sealed interface/class の場合に直下の子クラスが Sequence として取得できます。

ここら辺のプロパティはノリと勘で探し当てるか (結構そのまんまの名前になっているので探しやすいはず)、KSP の DeepWiki にやりたいことを投げると答えてくれたりします。😊

https://deepwiki.com/google/ksp

今回は @JsExport と sealed がついている interface/class を対象にしたいので以下のように組み合わせると実現できます。🎯

ksp-plugin/src/main/kotlin/me/tbsten/prac/kotlinjssealedksp/SealedPatternMatcherFunGenerator.kt
resolver
  .getSymbolsWithAnnotation("kotlin.js.JsExport")
  .filterIsInstance<KSClassDeclaration>()
  .filter { it.modifiers.contains(Modifier.SEALED) }
  .forEach { sealedClass: KSClassDeclaration ->
    val childClasses: Sequence<KSClassDeclaration> = sealedClass.getSealedSubclasses()
    ....
  }

Step4. 見つけた interface/class ごとに TypeScript ファイルを生成する。

今回 生成したい TypeScript コードを確認しておきましょう。📝

生成したい TypeScript コード (再掲)
import { ${CHILD_TYPE_NAMEjoinToString(", ") したもの} } from "${moduleName}"

export function when${SEALED_TYPE_NAME}<const R>(
  myScreenState: ${SEALED_TYPE_NAME},
  blocks: {
    // 子クラスごとに以下を繰り返す
    ${CHILD_VARIABLE_NAME}: (${CHILD_VARIABLE_NAME}: ${CHILD_TYPE_NAME}) => R,
  },
) {
  // 子クラスごとに分岐の if 文をを繰り返す
  if(${SEALED_VARIABLE_NAME} instanceof ${CHILD_TYPE_NAME}) {
    return blocks.errorState(myScreenState)
  } else {
    throw new TypeError(`Invalid typeof  type.`)
  }
}
プレースホルダーの意味
  • moduleName は JS のモジュール名 (KSP のオプションとして設定する)
  • SEALED~ は見つけた sealed interface
  • CHILD~ は sealed interface の各 子クラス
  • ~TYPE_NAME型名 として使う際の名前 (UpperCamelCase)
  • ~TYPE_NAME変数名 として使う際の名前 (lowerCamelCase)

前にも述べた通り、コード生成は コンストラクタで受け取った CodeGenerator を使って実装できます。🔧
CodeGenerator の基本的な使い方は以下の通りです。

codeGenerator.createNewFile(
  dependencies = Dependencies(...),
  packageName = "...",
  fileName = "...",
  extensionName = "...",
).bufferedWriter().use { writer: BufferedWriter ->
  writer.appendLine("...")
}
各設定の詳細
  • dependencies には生成するファイルが依存するファイル。ここに設定された内容をもとにキャッシュが働くため、キャッシュ関連で不具合が疑われる場合はここを除く価値がありそうです。
  • packageName ... Kotlin などのコード生成の場合は パッケージ名ですが、実質的には ディレクトリを "." 区切りで指定する場所になっています。
  • fileName ... ファイル名 (拡張子含まず)。
  • extensionName ... 生成するファイルの拡張子。

今回は以下のように設定しました。⚙️

codeGenerator.createNewFile(
  dependencies = Dependencies(
    aggregating = false, 
    sources = (listOf(sealedClass.containingFile) + childClasses.map { it.containingFile })
     .filterNotNull()
     .toTypedArray()
  ),
  packageName = "generated",
  fileName = sealedClass.simpleName.asString(),
  extensionName = "ts",
).bufferedWriter().use { writer: BufferedWriter ->
  ...
}

次に writer.appendLine("...") を呼び出してファイルにテキストを書いていくのですが、今回生成したいコードには何度も使用するプレースホルダーがあるので、先にそちらをユーティリティとして簡単に使えるようにしておきます。💡

ksp-plugin/src/main/kotlin/me/tbsten/prac/kotlinjssealedksp/SealedPatternMatcherFunGenerator.kt
// _TYPE_NAME 
private val KSClassDeclaration.typeName: String get() = this.simpleName.asString()

// _VARIABLE_NAME 
private val KSClassDeclaration.variableName: String get() = this.typeName.replaceFirstChar { it.lowercase() }

ここまでできたらあとは愚直に生成したいコードを writer.appendLine() していきます。😊

// import 文
val moduleName = options["SealedPatternMatcherFunGenerator.moduleName"]
  ?: error("ksp.arg(\"SealedPatternMatcherFunGenerator.moduleName\", \"...\") が設定されていません")

writer.appendLine("""import { ${(listOf(sealedClass) + childClasses).joinToString(", ") { it.typeName }} } from "$moduleName"""")

// 関数定義
writer.appendLine("""export function when${sealedClass.typeName}<const R>(""")
writer.appendLine("""  ${sealedClass.variableName}: ${sealedClass.typeName},""")
writer.appendLine("""  blocks: {""")
childClasses.forEach { childClass ->
  writer.appendLine("""    ${childClass.variableName}: (${childClass.variableName}: ${childClass.typeName}) => R,""")
}
writer.appendLine("""  },""")
writer.appendLine(""") {""")

// 分岐して 該当のブロックを実行する
childClasses.forEachIndexed { index, childClass ->
  writer.appendLine(
    """
    |${if (index == 0) "if" else "} else if"}(
    |${sealedClass.variableName} ${
    // object の場合は instanceof がうまく機能しないため 
    // getInstance() と一致するかチェックする
    if (childClass.classKind == ClassKind.OBJECT)
      " == ${childClass.typeName}.getInstance()"
    else
      " instanceof ${childClass.typeName}"
    }
    |) {""".trimMargin()
  )
  writer.appendLine("""    return blocks.${childClass.variableName}(${sealedClass.variableName})""")
}

// どれにも該当しなかった場合のエラー
writer.appendLine("""  } else {""")
writer.appendLine("""    throw new TypeError()""")
writer.appendLine("""  }""")

writer.appendLine("""}""")

最後に process メソッドは処理できなかった要素を返す必要がありますが 面倒なので 今回はあまり考慮に入れず return emptyList() で指定してしまいましょう 😅

(詳しくはMultiple round processing のドキュメント を参照)

fun process(resolver: Resolver): List<KSAnnotated> {
  ...
  return emptyList()
}

これで TypeScript ファイルを生成する SymbolProcessor が完成しました!🎉

`SealedPatternMatcherFunGenerator.kt` の全体
class SealedPatternMatcherFunGenerator(
    private val codeGenerator: CodeGenerator,
    private val options: Map<String, String>,
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        resolver
            .getSymbolsWithAnnotation("kotlin.js.JsExport")
            .filterIsInstance<KSClassDeclaration>()
            .filter { it.modifiers.contains(Modifier.SEALED) }
            .forEach { sealedClass: KSClassDeclaration ->
                val childClasses = sealedClass.getSealedSubclasses()
                sealedClass.packageName

                codeGenerator.createNewFile(
                    dependencies = Dependencies(
                        aggregating = false,
                        sources = (listOf(sealedClass.containingFile) + childClasses.map { it.containingFile })
                            .filterNotNull()
                            .toTypedArray()
                    ),
                    packageName = "generated",
                    fileName = sealedClass.simpleName.asString(),
                    extensionName = "ts",
                ).bufferedWriter().use { writer ->
                    // import 文
                    val moduleName = options["SealedPatternMatcherFunGenerator.moduleName"]
                        ?: error("ksp.arg(\"SealedPatternMatcherFunGenerator.moduleName\", \"...\") が設定されていません")

                    writer.appendLine("""import { ${(listOf(sealedClass) + childClasses).joinToString(", ") { it.typeName }} } from "$moduleName"""")

                    // 関数定義
                    writer.appendLine("""export function when${sealedClass.typeName}<const R>(""")
                    writer.appendLine("""  ${sealedClass.variableName}: ${sealedClass.typeName},""")
                    writer.appendLine("""  blocks: {""")
                    childClasses.forEach { childClass ->
                        writer.appendLine("""    ${childClass.variableName}: (${childClass.variableName}: ${childClass.typeName}) => R,""")
                    }
                    writer.appendLine("""  },""")
                    writer.appendLine(""") {""")

                    // 分岐して 該当のブロックを実行する
                    childClasses.forEachIndexed { index, childClass ->
                        writer.appendLine("""  ${if (index == 0) "if" else "} else if"}(${sealedClass.variableName} instanceof ${childClass.typeName}) {""")
                        writer.appendLine("""    return blocks.${childClass.variableName}(${sealedClass.variableName})""")
                    }

                    // どれにも該当しなかった場合のエラー
                    writer.appendLine("""  } else {""")
                    writer.appendLine("""    throw new TypeError()""")
                    writer.appendLine("""  }""")

                    writer.appendLine("""}""")
                }
            }

        return emptyList()
    }
}

private val KSClassDeclaration.typeName: String get() = this.simpleName.asString()
private val KSClassDeclaration.variableName: String get() = this.typeName.replaceFirstChar { it.lowercase() }

import 文のプレースホルダー moduleName を KSP の options から取得するように実装したので、忘れずに利用側のモジュールにも設定しておきましょう。

shared/build.gradle.kts
ksp {
    arg("SealedPatternMatcherFunGenerator.moduleName", "shared")
}

Step5. .ts ファイルを JS プロジェクトから読み込む

ここまでできていれば、KSP Plugin が動く準備は万端です!
:shared モジュールに sealed interface を置いて試してみましょう。🎯

shared/src/commonMain/kotlin/me/tbsten/prac/kotlinjssealedksp/MyScreenState.kt
@JsExport
sealed interface MyScreenState

@JsExport
data object LoadingState : MyScreenState

@JsExport
data class SuccessState(val data: List<String>) : MyScreenState

@JsExport
data class ErrorState(
    val error: Throwable,
) : MyScreenState

./gradlew jsBrowserDevelopmentLibraryDistribution を実行後、<module>/build/generated/ksp/js/jsMain/resources/generated ディレクトリ配下に生成したコードが配置されます。⚙️

実行して確認してみましょう。
無事 パターンマッチング関数 が生成されていることが確認できるはずです。✨

// shared/build/generated/ksp/js/jsMain/resources/generated/MyScreenState.ts
// に生成されているはず
import { MyScreenState, ErrorState, LoadingState, SuccessState } from "shared"
export function whenMyScreenState<const R>(
  myScreenState: MyScreenState,
  blocks: {
    errorState: (errorState: ErrorState) => R,
    loadingState: (loadingState: LoadingState) => R,
    successState: (successState: SuccessState) => R,
  },
) {
  if(myScreenState instanceof ErrorState) {
    return blocks.errorState(myScreenState)
  } else if(myScreenState == LoadingState.getInstance()) {
    return blocks.loadingState(myScreenState)
  } else if(myScreenState instanceof SuccessState) {
    return blocks.successState(myScreenState)
  } else {
    throw new TypeError()
  }
}

最後にこの生成したファイルを JS プロジェクトから使えるようにします。
KSP で生成したディレクトリから JS のディレクトリにコピーするようにしてもいいのですが、Gradle の設定が面倒なので、今回は KSP で生成したディレクトリを npm の workspaces を使って 読み込めるようにしてみます。

この方法は非常に手軽で、package.json をちょっと書き込むだけで生成した TS ファイルの読み込みができます。😊

  • ルートの package.json で workspaces を設定
  • webApp/package.json で dependencies に <module>-generated を追加
  • SealedPatternMatcherFunGenerator で package.json も生成するように
package.json (ルート)
{
  "workspaces": [
    // ...
    // 自動生成のディレクトリを指定
+   "shared/build/generated/ksp/js/jsMain/resources/generated"
  ],
}
webApp/package.json
{
  "dependencies": {
    // ...
+   "shared-generated": "0.0.0-unspecified"
  },
}

このようにして読み込む際には shared-generated にも package.json が必要です。📦
KSP Plugin を修正しましょう。

class SealedPatternMatcherFunGenerator(
    private val codeGenerator: CodeGenerator,
    private val options: Map<String, String>,
) : SymbolProcessor {
+   val moduleName = options["SealedPatternMatcherFunGenerator.moduleName"]
+       ?: error("ksp.arg(\"SealedPatternMatcherFunGenerator.moduleName\", \"...\") が設定されていません")
+
+   init {
+       codeGenerator.createNewFile(
+           dependencies = Dependencies(false),
+           packageName = "generated",
+           fileName = "package",
+           extensionName = "json",
+       ).bufferedWriter().use { writer ->
+           writer.appendLine(
+               """
+               {
+                 "name": "$moduleName-generated",
+                 "version": "0.0.0-unspecified",
+                 "devDependencies": {
+                   "typescript": "5.8.3"
+                 },
+                 "dependencies": {},
+                 "peerDependencies": {},
+                 "optionalDependencies": {},
+                 "bundledDependencies": []
+               }
+               """.trimIndent()
+           )
+       }
+   }

    override fun process(resolver: Resolver): List<KSAnnotated> {
        resolver
            .getSymbolsWithAnnotation("kotlin.js.JsExport")
            .filterIsInstance<KSClassDeclaration>()
            .filter { it.modifiers.contains(Modifier.SEALED) }
            .forEach { sealedClass: KSClassDeclaration ->
                val childClasses = sealedClass.getSealedSubclasses()
                sealedClass.packageName

                codeGenerator.createNewFile(
                    dependencies = Dependencies(
                        aggregating = false,
                        sources = (listOf(sealedClass.containingFile) + childClasses.map { it.containingFile })
                            .filterNotNull()
                            .toTypedArray()
                    ),
                    packageName = "generated",
                    fileName = sealedClass.simpleName.asString(),
                    extensionName = "ts",
                ).bufferedWriter().use { writer ->
                    // import 文
                    writer.appendLine("""import { ${(listOf(sealedClass) + childClasses).joinToString(", ") { it.typeName }} } from "$moduleName"""")

                    // 関数定義
                    writer.appendLine("""export function when${sealedClass.typeName}<const R>(""")
                    writer.appendLine("""  ${sealedClass.variableName}: ${sealedClass.typeName},""")
                    writer.appendLine("""  blocks: {""")
                    childClasses.forEach { childClass ->
                        writer.appendLine("""    ${childClass.variableName}: (${childClass.variableName}: ${childClass.typeName}) => R,""")
                    }
                    writer.appendLine("""  },""")
                    writer.appendLine(""") {""")

                    // 分岐して 該当のブロックを実行する
                    childClasses.forEachIndexed { index, childClass ->
                        writer.appendLine(
                            """  ${if (index == 0) "if" else "} else if"}(
                            |${sealedClass.variableName} ${
                                // object の場合は instanceof がうまく機能しないため 
                                // getInstance() と一致するかチェックする
                                if (childClass.classKind == ClassKind.OBJECT)
                                    " == ${childClass.typeName}.getInstance()"
                                else
                                    " instanceof ${childClass.typeName}"
                            }
                            |) {""".trimMargin()
                        )
                        writer.appendLine("""    return blocks.${childClass.variableName}(${sealedClass.variableName})""")
                    }

                    // どれにも該当しなかった場合のエラー
                    writer.appendLine("""  } else {""")
                    writer.appendLine("""    throw new TypeError()""")
                    writer.appendLine("""  }""")

                    writer.appendLine("""}""")
                }
            }

        return emptyList()
    }
}

これで JS から読み込めるようになったはずです ✨
index.tsx ファイルで 以下のように呼び出してみましょう。

alert(
  "STATE: " +
  whenMyScreenState(LoadingState.getInstance(), {
    errorState: (error) => `Error: ${error}`,
    loadingState: (_loading) => "Loading...",
    successState: (success) => `Success: ${success.data}`
  })
)

const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');

アプリを実行するには以下のコマンドを実行します。

# Kotlin/JS のビルドをし直して、依存関係をインストールし直す
./gradlew jsBrowserDevelopmentLibraryDistribution && npm i 

# 開発サーバを起動
npm start

表示された URL にアクセスすると きちんと alert が表示されました 🎉

🎯 まとめ

この記事では、Kotlin/JS で sealed interface の網羅性が失われる問題を、KSP を使って TypeScript のパターンマッチング関数を自動生成することで解決しました!🙌

  • 型安全性の確保: TypeScript からでも sealed interface の網羅性を保証
  • 開発効率の向上: 手動でパターンマッチング関数を書く必要がなくなった
  • 保守性の向上: sealed interface を追加・削除するだけで自動的に TypeScript 関数も更新される

Discussion