Kotlin Idioms skill for Claude Code

No !!, no GlobalScope, no leaked coroutines. Claude writes Kotlin that resolves nulls at the edge, models screens and results as sealed types with exhaustive when, and launches every coroutine in a scope something owns. It picks Flow, StateFlow, SharedFlow or Channel deliberately, keeps suspend functions main-safe, rethrows CancellationException, and tests timing with virtual time instead of sleeps.

  • namekotlin-idioms
  • size218 lines · ≈2,100 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes idiomatic Kotlin 2.x — null safety without !!, data, sealed and value classes, exhaustive when, scope functions used sparingly, extension functions, and structured concurrency with coroutines, StateFlow, and Flow operators, plus coroutine testing with runTest and Turbine. Use when writing or reviewing Kotlin for Android, Ktor, or Spring, converting Java to Kotlin, fixing coroutine leaks or cancellation bugs, or choosing between Flow, StateFlow, and SharedFlow.

Try asking

  • Convert this Java class to idiomatic Kotlin without any !! operators
  • This ViewModel leaks coroutines after the screen closes — fix the scoping
  • Should this event stream be a StateFlow, SharedFlow, or Channel?

Use it when

  • Writing or reviewing Kotlin for Android, Ktor, or Spring
  • Converting Java code to Kotlin
  • Debugging coroutine cancellation, leaks, or dispatcher misuse
  • Testing suspend functions and flows

Skip it when

How to check the result

ktlint and detekt pass, the count of !! in main sources did not rise, GlobalScope is absent, and coroutine tests pass under runTest virtual time.

The common mistake it prevents

Wrapping suspend calls in runCatching or catch (e: Exception), which swallows CancellationException and keeps work running after its scope was cancelled.

~/.claude/skills/kotlin-idioms/SKILL.md

--- name: kotlin-idioms description: Writes idiomatic Kotlin 2.x — null safety without !!, data, sealed and value classes, exhaustive when, scope functions used sparingly,… license: MIT ---

Done well means: no !! in production paths, domain states are sealed types matched by exhaustive when, every coroutine is launched in a scope with an owner, cancellation propagates, flows are cold until collected by a lifecycle-aware collector, and detekt/ktlint pass.

Ground rules

  • Nullability is part of the type. Resolve it at the boundary (?: return, ?: error()), not with !! deep in logic.
  • val by default; var only for local accumulation. Expose read-only collection types (List, Map), keep MutableList private.
  • Never use GlobalScope. Never launch a coroutine without knowing which scope cancels it.
  • Never swallow CancellationException. Catching Exception in a suspend function must rethrow it.
  • Prefer expression bodies and when expressions where they read naturally; stop when a chain needs a comment to follow.

Inspect before changing

grep -rnE 'kotlin\("jvm"\)|org.jetbrains.kotlin|kotlinx-coroutines' build.gradle* gradle/libs.versions.toml 2>/dev/null
grep -rn "!!" --include=*.kt src | wc -l
grep -rn "GlobalScope\|runBlocking" --include=*.kt src

runBlocking belongs only in main and tests (and even there runTest is preferred).

Null safety

fun shippingLabel(order: Order): String {
    val address = order.customer?.address ?: return "NO ADDRESS"
    val zip = requireNotNull(address.zip) { "address ${address.id} missing zip" }
    return "${address.line1}, $zip"
}
SituationIdiom
Default valuename ?: "anonymous"
Early exitval x = y ?: return / ?: continue
Invariant violatedrequireNotNull(x) { msg } (argument) / checkNotNull (state)
Optional transformuser?.email?.lowercase()
Run block if non-nullx?.let { use(it) } (only when it replaces an if cleanly)
Platform type from JavaAnnotate Java side or assign to an explicit String? immediately

lateinit only for DI/test setup fields that are always set before use; never for values that may legitimately be absent.

Modeling

sealed interface UiState<out T> {
    data object Loading : UiState<Nothing>
    data class Success<T>(val data: T) : UiState<T>
    data class Error(val message: String, val retryable: Boolean) : UiState<Nothing>
}

fun render(state: UiState<List<Item>>) = when (state) {   // exhaustive, no else
    UiState.Loading -> showSpinner()
    is UiState.Success -> showItems(state.data)
    is UiState.Error -> showError(state.message)
}

@JvmInline value class UserId(val value: String)          // zero-cost type safety
  • Data classes for values; avoid var properties in them (breaks hashCode in sets/maps).
  • Omit else in when over sealed types and enums so new cases fail to compile.
  • data object for stateless cases (readable toString).
  • Guard conditions in when (is Error if state.retryable ->) are stable from Kotlin 2.2; check the project’s Kotlin version first.

Scope functions

FunctionReceiver asReturnsUse for
letitlambda resultnull-check + transform
applythisreceiverconfiguring a new object
alsoitreceiverside effects (logging) in a chain
runthislambda resultcompute from an object’s members
withthislambda resultseveral calls on one non-null object

Rule: one scope function per expression. Nested let { ... let { } } or it that shadows an outer it must be rewritten with named lambdas or plain if.

Extension functions

  • Use for genuinely general helpers on types you do not own (fun String.toSlug()), or to keep a class small by moving optional conveniences out.
  • Do not use them to reach into another module’s internals or as a substitute for a member that needs private state.
  • Keep them in a file named for the receiver (StringExt.kt) or next to the only caller.

Coroutines: structure

class OrderRepository(private val api: Api, private val io: CoroutineDispatcher = Dispatchers.IO) {
    suspend fun loadDashboard(id: String): Dashboard = coroutineScope {
        val order = async { api.order(id) }        // both run concurrently
        val history = async { api.history(id) }
        Dashboard(order.await(), history.await()) // one failure cancels the other
    }

    suspend fun export(file: File, rows: List<Row>) = withContext(io) {
        file.bufferedWriter().use { w -> rows.forEach { w.appendLine(it.csv()) } }
    }
}
NeedUse
Parallel subtasks, fail togethercoroutineScope { async {}; async {} }
Parallel subtasks, failures independentsupervisorScope + handle each result
Switch thread for blocking I/OwithContext(Dispatchers.IO) inside the suspend fun
Fire-and-forget tied to a screenviewModelScope.launch / lifecycleScope.launch
App-wide background workInjected CoroutineScope(SupervisorJob() + dispatcher) owned by the app
Limit parallelismDispatchers.IO.limitedParallelism(4) or Semaphore(4).withPermit {}
TimeoutwithTimeout(5.seconds) { } / withTimeoutOrNull
  • Suspend functions must be main-safe: callers should never need to wrap them in withContext.
  • Inject dispatchers so tests can replace them.
  • Make long CPU loops cancellable with ensureActive() or yield().

Cancellation-safe error handling:

try {
    api.sync()
} catch (e: CancellationException) {
    throw e
} catch (e: IOException) {
    log.warn("sync failed", e)
}

runCatching { } catches CancellationException too; do not use it around suspend calls unless you rethrow cancellation.

Flow

TypeHot/coldUse
Flow<T>coldStreams from DB/network, recomputed per collector
StateFlow<T>hot, has value, conflatedUI state; always has a current value
SharedFlow<T>hot, no valueEvents broadcast to many collectors
Channel<T>hot, single consumerOne-shot events consumed exactly once
class SearchViewModel(repo: Repo) : ViewModel() {
    private val query = MutableStateFlow("")
    val results: StateFlow<UiState<List<Item>>> = query
        .debounce(300)
        .distinctUntilChanged()
        .mapLatest { q -> UiState.Success(repo.search(q)) as UiState<List<Item>> }  // cancels stale searches
        .catch { emit(UiState.Error(it.message ?: "error", retryable = true)) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Loading)

    fun onQuery(q: String) { query.value = q }
}
  • flowOn(Dispatchers.IO) changes the upstream context; never withContext inside flow { } around emit.
  • On Android collect with repeatOnLifecycle(Lifecycle.State.STARTED) or collectAsStateWithLifecycle() in Compose.
  • catch only handles upstream errors; place it after the operators it should guard.
  • Update StateFlow atomically with _state.update { it.copy(...) }, not read-modify-write.

Testing coroutines and flows

@Test fun `emits success after search`() = runTest {
    val vm = SearchViewModel(FakeRepo(listOf(item)))
    vm.results.test {                                  // Turbine
        assertEquals(UiState.Loading, awaitItem())
        vm.onQuery("shoe")
        advanceTimeBy(301)
        assertEquals(UiState.Success(listOf(item)), awaitItem())
        cancelAndIgnoreRemainingEvents()
    }
}
  • runTest skips delay with virtual time; pass StandardTestDispatcher(testScheduler) into classes that take a dispatcher.
  • For code using Dispatchers.Main, call Dispatchers.setMain(testDispatcher) in setup and resetMain() in teardown.

Java to Kotlin conversion checklist

- [ ] Run the IDE converter, then remove every !! it introduced
- [ ] Replace getters/setters + equals/hashCode classes with data classes
- [ ] Replace static utils with top-level functions or extensions
- [ ] Replace builders with named/default arguments
- [ ] Replace callbacks/Futures with suspend functions at the boundary
- [ ] Add @JvmStatic / @JvmOverloads only where Java callers remain

Verification

./gradlew ktlintCheck detekt test     # task names depend on applied plugins; check build files
grep -rn "!!" --include=*.kt src/main | wc -l   # should not increase
grep -rn "GlobalScope" --include=*.kt src/main  # should be empty

Tests that involve time use runTest virtual time, not real delay or Thread.sleep.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc