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.
- name
kotlin-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
- Building Compose UI layouts and state hoisting — use jetpack-compose
- Writing plain Java 21 — use java-modern
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.
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. valby default;varonly for local accumulation. Expose read-only collection types (List,Map), keepMutableListprivate.- Never use
GlobalScope. Never launch a coroutine without knowing which scope cancels it. - Never swallow
CancellationException. CatchingExceptionin a suspend function must rethrow it. - Prefer expression bodies and
whenexpressions 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"
}
| Situation | Idiom |
|---|---|
| Default value | name ?: "anonymous" |
| Early exit | val x = y ?: return / ?: continue |
| Invariant violated | requireNotNull(x) { msg } (argument) / checkNotNull (state) |
| Optional transform | user?.email?.lowercase() |
| Run block if non-null | x?.let { use(it) } (only when it replaces an if cleanly) |
| Platform type from Java | Annotate 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
varproperties in them (breakshashCodein sets/maps). - Omit
elseinwhenover sealed types and enums so new cases fail to compile. data objectfor stateless cases (readabletoString).- Guard conditions in
when(is Error if state.retryable ->) are stable from Kotlin 2.2; check the project’s Kotlin version first.
Scope functions
| Function | Receiver as | Returns | Use for |
|---|---|---|---|
let | it | lambda result | null-check + transform |
apply | this | receiver | configuring a new object |
also | it | receiver | side effects (logging) in a chain |
run | this | lambda result | compute from an object’s members |
with | this | lambda result | several 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()) } }
}
}
| Need | Use |
|---|---|
| Parallel subtasks, fail together | coroutineScope { async {}; async {} } |
| Parallel subtasks, failures independent | supervisorScope + handle each result |
| Switch thread for blocking I/O | withContext(Dispatchers.IO) inside the suspend fun |
| Fire-and-forget tied to a screen | viewModelScope.launch / lifecycleScope.launch |
| App-wide background work | Injected CoroutineScope(SupervisorJob() + dispatcher) owned by the app |
| Limit parallelism | Dispatchers.IO.limitedParallelism(4) or Semaphore(4).withPermit {} |
| Timeout | withTimeout(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()oryield().
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
| Type | Hot/cold | Use |
|---|---|---|
Flow<T> | cold | Streams from DB/network, recomputed per collector |
StateFlow<T> | hot, has value, conflated | UI state; always has a current value |
SharedFlow<T> | hot, no value | Events broadcast to many collectors |
Channel<T> | hot, single consumer | One-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; neverwithContextinsideflow { }aroundemit.- On Android collect with
repeatOnLifecycle(Lifecycle.State.STARTED)orcollectAsStateWithLifecycle()in Compose. catchonly 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()
}
}
runTestskipsdelaywith virtual time; passStandardTestDispatcher(testScheduler)into classes that take a dispatcher.- For code using
Dispatchers.Main, callDispatchers.setMain(testDispatcher)in setup andresetMain()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.