Writes, debugs, and reviews Kotlin: coroutines and flows, null safety, collections, Java interop, and Compose state. Use when an NPE hits a non-null type, `!!` or a Java platform type blows up, a coroutine leaks, never cancels, or swallows exceptions, a StateFlow stops emitting or a SharedFlow drops events, a Compose screen recomposes too much or loses state on rotation, `equals`/`copy`/`==` behave unexpectedly, a `when` stops being exhaustive, kapt/KSP or JVM-target errors break the Gradle build, JSON puts null into a non-null property, a coroutine test hangs or passes only in isolation; also when porting Java to Kotlin, sharing code across Android/iOS/JVM, tuning allocation and inlining, or writing server-side Kotlin with Spring or Ktor. Not for Java-only codebases or Android release/build-system configuration.
---
name: Kotlin
slug: kotlin
version: 1.0.3
changelog: "Display name shown correctly"
description: >-
Writes, debugs, and reviews Kotlin: coroutines and flows, null safety, collections, Java interop, and Compose state.
Use when an NPE hits a non-null type, `!!` or a Java platform type blows up, a coroutine leaks, never cancels, or swallows
exceptions, a StateFlow stops emitting or a SharedFlow drops events, a Compose screen recomposes too much or loses state on
rotation, `equals`/`copy`/`==` behave unexpectedly, a `when` stops being exhaustive, kapt/KSP or JVM-target errors break the
Gradle build, JSON puts null into a non-null property, a coroutine test hangs or passes only in isolation; also when porting
Java to Kotlin, sharing code across Android/iOS/JVM, tuning allocation and inlining, or writing server-side Kotlin with Spring
or Ktor. Not for Java-only codebases or Android release/build-system configuration.
homepage: https://clawic.com/skills/kotlin
metadata:
clawdbot:
emoji: π
requires:
bins:
- kotlin
os:
- linux
- darwin
- win32
displayName: Kotlin
configPaths:
- ~/Clawic/data/kotlin/
---
User preferences and memory live in `~/Clawic/data/kotlin/` (see `setup.md` on first use, `memory-template.md` for the file format). If you have data at an old location (`~/kotlin/` or `~/clawic/kotlin/`), move it to `~/Clawic/data/kotlin/`.
## Configuration
User-dependent variables. Defaults apply until the user states a preference; store them in `~/Clawic/data/kotlin/config.yaml`.
| Variable | Type | Default | Effect |
|---|---|---|---|
| target_platform | auto \| android \| server \| multiplatform \| library | auto | Picks the default deep-dive file and the idiom set; `auto` infers from build files (AGP plugin β android, Ktor/Spring dependency β server, `kotlin("multiplatform")` β multiplatform) |
| kotlin_floor | text (e.g. "2.0", "1.9") | 2.0 | Gates every version-dependent recommendation in `build.md`; below 2.0 the K2-only and Compose-compiler-plugin advice is suppressed |
| ui_toolkit | compose \| views \| none | compose | Switches Android examples between `compose.md` state handling and View/ViewBinding lifecycle patterns in `android.md` |
| serialization_lib | kotlinx \| moshi \| gson \| jackson | kotlinx | Selects the annotation set, config defaults, and null-safety warnings in `serialization.md` |
| annotation_processor | ksp \| kapt \| none | ksp | Drives library setup snippets and the build-speed advice in `build.md` |
| explicit_api | bool | false | When true, emitted library code carries explicit visibility and public return types, matching `explicitApi()` strictness |
Preference areas to record as the user reveals them:
- **tooling** β DI (Hilt, Koin, manual), networking client (Retrofit, Ktor client), persistence (Room, SQLDelight, Exposed, JPA), test stack (JUnit4/5, MockK, flow-testing library, Kotest) β affects which examples and setup snippets are offered
- **conventions** β package layout (feature vs layer), formatter/linter (ktlint, ktfmt, detekt), trailing commas, explicit types on public API, naming of sealed state hierarchies β affects generated code and review verdicts
- **platform** β JVM toolchain version, Android min SDK, KMP target list, whether Java sources coexist in the module β affects which floors and interop rules apply
- **safety posture** β tolerance for `!!`, experimental/opt-in APIs, warning suppression, `allWarningsAsErrors` β affects how hard to push back in review
- **legacy bridges** β RxJava, LiveData, callback APIs still in the codebase β affects whether answers stay pure-coroutine or include adapters
- **output format** β KDoc density, tests emitted alongside code, snippet vs full file β affects the shape of every deliverable
## When To Use
- Writing or reviewing Kotlin for correctness: nullability, coroutines, collections, sealed hierarchies, equality
- Debugging Kotlin runtime surprises: NPE on a non-null type, coroutine that never finishes or never cancels, flow that stops emitting, recomposition storm, test that hangs
- Java interop and JavaβKotlin migration, including making Kotlin APIs pleasant to call from Java
- Build and compiler problems that are Kotlin's, not Gradle's: kapt/KSP, JVM-target mismatch, opt-in, K2 migration
- Sharing code across Android, iOS, JVM, or web with Kotlin Multiplatform, or writing server-side Kotlin
- Not for Java-only codebases (β `java`), Android release configuration, signing and distribution (β `android`), or IDE workflow (β `android-studio`)
## Quick Reference
| Situation | Go to |
|---|---|
| NPE on a non-null type, platform type from Java, `lateinit` not initialized, smart cast refused | `null-safety.md` |
| Coroutine leaks, never cancels, blocks the main thread, wrong dispatcher, `runBlocking` in production | `coroutines.md` |
| StateFlow stops emitting, SharedFlow drops events, `stateIn`/`shareIn` choice, backpressure, cold vs hot | `flows.md` |
| Exception vanishes, `async` failure surfaces late, `runCatching` breaks cancellation, error type design | `errors.md` |
| Wrong list/map/sequence choice, O(nΒ²) lookup, mutation leaking through a read-only type | `collections.md` |
| Scope functions, sealed hierarchies, delegation, destructuring, `use`, DSL builders | `idioms.md` |
| Variance/`out`/`in` compile error, type erasure, `reified`, inline/`crossinline`, contracts | `generics.md` |
| Calling Kotlin from Java, `@Jvm*` annotations, SAM, checked exceptions, converting a Java file | `interop.md` |
| Lifecycle, ViewModel, SavedStateHandle, process death, leaked Context, flow collection on Android | `android.md` |
| Recomposition storms, `remember` vs `rememberSaveable`, stability, side effects, lazy lists | `compose.md` |
| Coroutine test hangs, passes alone but fails in a suite, virtual time, flow assertions, mocking final classes | `testing.md` |
| Allocation in a hot path, boxing, sequences vs lists, inlining cost, value classes, benchmarking | `performance.md` |
| kapt vs KSP, JVM-target mismatch, opt-in/experimental, K2, slow incremental builds, library API rules | `build.md` |
| JSON null in a non-null property, defaults dropped, polymorphic types, unknown keys, Parcelize | `serialization.md` |
| `expect`/`actual`, source sets, Swift/ObjC interop, suspend and Flow across the iOS boundary | `multiplatform.md` |
| Spring/Ktor, JPA entity as data class, blocking JDBC in a coroutine, MDC/ThreadLocal lost | `server.md` |
| Anything else | The rules and tables below; for pure language semantics, `idioms.md` then `generics.md`; with no situational match at all, open the file `target_platform` resolves to (`android` β `android.md`, `server` β `server.md`, `multiplatform` β `multiplatform.md`, `library` β `build.md`) |
## Core Rules
1. **Nullability is a boundary problem.** Non-null Kotlin types are only as true as the code that fills them: Java, reflection-based JSON, and framework callbacks can all put null into a `String`. Validate at the boundary β parse into a nullable DTO, then map into a non-null domain type. An NPE thrown at the boundary names the field; one thrown three layers later names nothing.
2. **`!!` requires an alternative to have been rejected.** Decision order: value arrives later on one thread β `lateinit var`; computed once on first use β `by lazy`; legitimately absent β nullable + `?:`; caller broke a contract β `requireNotNull(x) { "id missing" }` (throws `IllegalArgumentException`) or `checkNotNull` (`IllegalStateException`). Each of those produces a message; `!!` produces a line number and nothing else.
3. **Every coroutine belongs to a scope whose lifetime is β₯ the work's.** `GlobalScope`, and a `CoroutineScope(Dispatchers.IO)` stored in a field with no cancellation, are leaks by construction: an abandoned screen keeps its network call, its callbacks, and everything they captured. Structured concurrency is the guarantee that "the caller returned" means "the work is over" (β `coroutines.md`).
4. **Cancellation is cooperative.** A cancelled coroutine keeps burning CPU until it reaches a suspension point: `while (true) { transform(chunk) }` with no `suspend` call inside never stops. Add `ensureActive()` (or `yield()`) per iteration. Cleanup that suspends after cancellation must run inside `withContext(NonCancellable)`, or it is cancelled before it does anything.
5. **Never swallow `CancellationException`.** `catch (e: Exception)`, `catch (t: Throwable)` and `runCatching` all capture it, converting "the parent cancelled me" into "I failed" β and the parent then waits on a child that reported success. Shape: `catch (e: CancellationException) { throw e } catch (e: IOException) { β¦ }` β cancellation first, specific types after (β `errors.md`).
6. **StateFlow conflates by `equals`.** Emitting a value equal to the current one runs no collector. Mutate a list in place and re-assign the same reference and nothing is emitted (the new value equals the old one), so the UI "stops updating" with no error anywhere. Publish a new immutable value: `_state.update { it.copy(items = it.items + new) }`.
7. **Choose the dispatcher by what the code blocks on.** CPU work β `Dispatchers.Default` (parallelism = CPU cores, minimum 2). Blocking calls, JDBC, file I/O, legacy SDKs β `Dispatchers.IO` (64 threads by default, property `kotlinx.coroutines.io.parallelism`). UI β `Dispatchers.Main`. For a bounded resource, size the slice to the resource: a 10-connection pool wants `Dispatchers.IO.limitedParallelism(10)`, not 64 threads queueing for 10 connections.
8. **`equals`, `hashCode` and `copy` see only the constructor properties.** A property declared in the class body is invisible to all three: two objects differing only there are equal, collide in a `HashSet`, and `copy()` silently resets it to its initializer. `copy()` is also shallow β the copy shares every mutable object the original held.
9. **Make `when` an expression.** Assigned or returned, a `when` over a sealed type is checked for exhaustiveness, so adding a subclass breaks the build at every decision point that must change. As a bare statement it has only been an error since Kotlin >=1.7, and an `else` branch over a sealed hierarchy silently absorbs every case you add later.
## Nullability Decision Table
| The value | Use | Why not the others |
|---|---|---|
| May genuinely be absent in the domain | `T?` with `?:` / `?.let` | Absence is data, not an error state |
| Set once before first use, non-primitive, no sensible default | `lateinit var` | Nullable would push `?.` into every call site; `lateinit` fails loudly with "property X has not been initialized" |
| Expensive, computed on first read | `by lazy` | Thread-safe by default (SYNCHRONIZED), and no initialization-order trap |
| Required by contract, may be violated by a caller | `requireNotNull` / `checkNotNull` | Produces a message and the right exception type at the boundary |
| Primitive type, or null is an acceptable value | nullable or a default value | `lateinit` supports neither primitives nor nullable types |
Generic caveat: an unbounded `T` includes `T?`, so `fun <T> firstOr(x: T): T` happily accepts null. Write `<T : Any>` when the parameter must be non-null.
## Concurrency Primitive Selection
| Need | Primitive | Trap it avoids |
|---|---|---|
| Work tied to a lifecycle | `scope.launch` | Detached work that outlives its owner |
| Two independent results, both required | `async` + `awaitAll` | Sequential awaits that double latency |
| Move blocking work off the caller's thread | `withContext(Dispatchers.IO)` | Blocking a UI or request thread |
| Fan-out where one failure must not kill the rest | `supervisorScope` | One failed child cancelling its siblings |
| A stream of values over time | `Flow` (cold) | Manual callback registration and its leaks |
| Current state, always has a value | `StateFlow` | A late subscriber with nothing to render |
| One-shot events no subscriber may miss | `Channel` | Navigation/toast events dropped during a configuration change (β `flows.md`) |
| Mutual exclusion inside suspend code | `Mutex.withLock` | `synchronized` pins a thread while a coroutine holds the lock across a suspension |
| Bounded producer/consumer pipeline | `Channel(capacity)` | Unbounded buffering that becomes a memory leak |
## Equality, Copy, And Identity
- `==` calls `equals` (structural); `===` compares references. This is the opposite convention to Java β a Java-trained reviewer reads `==` as identity and approves a real bug.
- Floating point has two behaviours. With a static type of `Double`/`Float`, `==` is IEEE 754: `Double.NaN == Double.NaN` is false and `0.0 == -0.0` is true. Once the values go through a boxed or generic path (`listOf(Double.NaN).contains(Double.NaN)`, `sortedBy`, `distinct`), Kotlin switches to total order: `NaN` equals itself and `-0.0 < 0.0`. Same values, two answers, chosen by the static type.
- `hashCode` must follow `equals`: a hand-written `equals` with no `hashCode` produces objects that are equal to each other and never found in a `HashMap`.
- A `data class` holding an `Array` compares by reference, because `Array.equals` is identity. Use `List`, or write `equals`/`hashCode` with `contentEquals`/`contentHashCode`.
- Value classes (`@JvmInline value class`) are the underlying type at runtime *until* they are used as a generic argument, made nullable, or passed through an implemented interface β then they box (β `performance.md`).
## Traps
| Trap | Why it fails | Do instead |
|---|---|---|
| `x?.let { β¦ } ?: fallback` | The Elvis also fires when the lambda returns null β the fallback runs even though `x` was non-null | `if (x != null) { β¦ } else { β¦ }`, or keep the lambda's result non-null |
| `runBlocking` to call a suspend function | Blocks the calling thread; on a UI or request thread it is a freeze, and inside a coroutine it can deadlock the dispatcher | Make the caller `suspend`, or use the scope that owns the work; `runBlocking` belongs in `main` and in tests |
| `GlobalScope.launch` for convenience | Nothing cancels it, and everything it captured lives until process death | A scope owned by the lifecycle of the work (β `coroutines.md`) |
| Collecting a flow in `onCreate` or a bare scope | Collection keeps running while the screen is invisible and can touch a destroyed view | `repeatOnLifecycle(STARTED)` or `collectAsStateWithLifecycle` (β `android.md`) |
| `@Entity data class` (JPA) | Generated `equals`/`hashCode` break against lazy proxies and against an id assigned at persist time | Regular class with id-based `equals` (β `server.md`) |
| Reflection-based JSON into non-null properties | The instance is built without calling the constructor, so defaults are skipped and non-null fields hold null | kotlinx.serialization, or Moshi with codegen (β `serialization.md`) |
| `it` in nested lambdas | The inner `it` shadows the outer one; it compiles and does the wrong thing | Name the parameter the moment lambdas nest |
| `companion object` for constants | Every `val` becomes a getter call, and Java callers must go through `Companion` | `const val` for primitives and strings, top-level or in the companion |
| Mutable `var` in a class used as a `Map` key | `hashCode` changes after insertion and the entry becomes unreachable | `val` properties for anything used as a key or put in a `Set` |
| Custom getter or `open var` used after a null check | Smart cast is refused because the value can change between reads; the reflex fix is `!!` | Assign to a local `val` and smart-cast that |
| `try/catch` wrapped around `flow.collect` | Breaks exception transparency and hides which stage failed | The `catch` operator upstream of `collect` (β `errors.md`) |
## Output Gates
Before emitting Kotlin code or a review verdict, verify:
- No `!!` that rule 2's decision order could have replaced?
- Every coroutine started in a scope something cancels, and every long CPU loop cooperating with cancellation?
- No `catch (Exception)` or `runCatching` around suspend code without re-throwing `CancellationException`?
- Every blocking call inside `withContext` on a dispatcher sized for the resource, and never on `Main`?
- Every `when` over a sealed type used as an expression, with no `else` covering future subclasses?
- Library module: explicit visibility and return types if `explicit_api` is on, plus `@Jvm*` annotations wherever Java calls in?
- State published as a new immutable value, not a mutated collection re-emitted through the same reference?
## Where Experts Disagree
- **Typed failures vs exceptions.** Return a typed failure for expected outcomes (validation, not-found, offline); throw for broken invariants and unrecoverable I/O. The test that settles most arguments: if every caller writes `try/catch` around it, it was never exceptional. The contested part is `kotlin.Result` as a public return type β it boxes, it cannot be matched exhaustively, and it hides which failures exist, while a sealed hierarchy enumerates them (β `errors.md`).
- **One-shot events: `Channel` vs state.** A `Channel` delivers exactly once to one consumer and survives a gap between subscribers; modelling the event as a field of the UI state with an explicit "consumed" acknowledgement survives process death but costs a round trip. Both are defensible. `SharedFlow(replay = 0)` is the option that quietly drops events while nobody is collecting.
- **Mocks vs fakes.** Kotlin classes are final by default, so mocking frameworks need bytecode tricks that make brittle tests brittler; a hand-written fake implementing the interface survives refactors. Mocks earn their keep for verifying interactions with something you do not own (β `testing.md`).
- **Scope-function density.** One camp reads `apply`/`also`/`run` chains as idiomatic Kotlin, the other as write-only code. The neutral line: a scope function that removes a temporary variable is a win; nesting beyond one level is a named function waiting to be extracted.
## Related Skills
More Clawic skills, get them at https://clawic.com/skills/kotlin (install if the user confirms):
- `java` β the language on the other side of every interop boundary
- `android` β Android build system, release configuration, and deployment
- `android-studio` β IDE workflow: debugging, profiling, refactoring
- `swift` β the Swift side when Kotlin Multiplatform ships an iOS framework
## Feedback
- If useful, star it: https://clawic.com/skills/kotlin
- Latest version: https://clawic.com/skills/kotlin
Part of [Clawic](https://clawic.com), the verified skill library. Get this skill: https://clawic.com/skills/kotlin.
don't have the plugin yet? install it then click "run inline in claude" again.