Kotlin Idioms skill for Codex
Kotlin that stops reading like Java. Codex rewrites Kotlin that still thinks in Java: nulls handled at the boundary instead of with !!, state modelled as sealed interfaces and data classes so when stays exhaustive, value classes for ids, and scope functions chosen for what they return. It checks Java platform types after conversions, uses Kotlin 2.2 features only when the compiler allows, and leaves detekt and ktlint clean.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- kotlin-idioms Installs to ~/.agents/skills/kotlin-idioms/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install kotlin-idioms The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project kotlin-idioms Installs to .agents/skills/kotlin-idioms/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/kotlin-idioms.zipunzip -o kotlin-idioms.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) kotlin-idioms PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $kotlin-idioms, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and reviews idiomatic Kotlin 2.x - null safety without !!, data classes, sealed interfaces with exhaustive when, value classes, scope functions used with intent, extension functions, collection versus sequence pipelines, require and check preconditions, Java interop annotations, and Kotlin 2.2 additions such as when guards. Use when converting Java to Kotlin, reviewing Kotlin that reads like Java, removing !! and lateinit misuse, modelling state with sealed types, or setting up detekt and ktlint rules.
Use it when
- You are converting Java code to Kotlin or reviewing Kotlin written by Java developers
- Code is full of !!, lateinit or nullable fields that are never null
- UI or domain state needs a closed set of cases with exhaustive handling
- A team wants detekt or ktlint rules that match idiomatic style
Use something else when
- The problem is coroutines, Flow or cancellation: use kotlin-coroutines
- You are sharing Kotlin code across Android and iOS: use kotlin-multiplatform
How to check the result
The build passes, detekt and ktlint report no new issues, and the diff has no unexplained !!, no else branch on sealed when expressions, and no mutable collections in public signatures.
The mistake it prevents
Trusting the IDE's Java-to-Kotlin conversion, which leaves platform types and !! everywhere that crash on the first unexpected null.
Done well means: nullability is expressed in types and handled at the boundary, !! appears
only where a comment proves the value cannot be null, state is modelled with sealed types
and data classes so when is exhaustive, and the code reads as Kotlin rather than Java
with different keywords.
Before changing code
- Check the Kotlin version (
kotlin("jvm") versionor the catalog) and the K2 compiler (default since 2.0). Features below note their minimum version. - Read
AGENTS.mdand.editorconfig/detekt.ymlfor style rules; follow them over this skill where they differ. - For Java-to-Kotlin conversion, the IDE converter output is a first draft; review every platform type it produced.
Null safety
| Situation | Idiom |
|---|---|
| Default when null | val name = user.nickname ?: user.email |
| Act only if not null | user?.let { audit(it) }, or a plain if (user != null) (smart cast) |
| Fail fast on invalid argument | requireNotNull(id) { "id missing for $type" } |
| Fail fast on invalid state | checkNotNull(session) { "not logged in" }, check(isOpen) |
| Early return | val token = header ?: return Unauthorized |
| Late init of DI / test fields | lateinit var (non-null, non-primitive only); never as a “maybe null” |
| Lazy computed once | val parser by lazy { buildParser() } |
| Values from Java | Treat as nullable unless annotated @NonNull/@Nullable (JSpecify, JetBrains) |
!! is a bug report waiting to happen. Replace it with one of the above; if it must stay,
add a comment explaining why null is impossible.
Modelling data and state
data class Money(val amountMinor: Long, val currency: Currency) {
init { require(amountMinor >= 0) { "negative amount: $amountMinor" } }
operator fun plus(other: Money): Money {
require(currency == other.currency) { "currency mismatch" }
return copy(amountMinor = amountMinor + other.amountMinor)
}
}
@JvmInline
value class OrderId(val value: String) // type-safe id, no allocation in most uses
sealed interface LoadState<out T> {
data object Loading : LoadState<Nothing>
data class Success<T>(val data: T) : LoadState<T>
data class Failure(val error: Throwable, val retryable: Boolean) : LoadState<Nothing>
}
fun render(state: LoadState<List<Item>>): String = when (state) {
LoadState.Loading -> "Loading"
is LoadState.Success -> "${state.data.size} items"
is LoadState.Failure if state.retryable -> "Retry?" // guard, Kotlin 2.2+
is LoadState.Failure -> "Error: ${state.error.message}"
}
- Use
whenas an expression over sealed types and enums withoutelse, so new subtypes are compile errors. On Kotlin before 2.2, replace the guard with a nestedif. data object(1.9+) for stateless sealed members gives a readabletoString.- Data classes are for values. Do not use them for JPA entities or classes with identity;
equals/hashCode/copyover mutable fields cause subtle bugs. - Prefer
valand read-only collection types (List,Map) in APIs; mutate locally withbuildList { }/buildMap { }.
Scope functions: pick by intent
| Function | Receiver as | Returns | Use for |
|---|---|---|---|
let | it | lambda result | Null-safe transform: x?.let(::parse) |
apply | this | the object | Configuring a new object |
also | it | the object | Side effects in a chain (logging, validation) |
run | this | lambda result | Compute a value from an object’s members |
with(x) | this | lambda result | Several calls on one non-null object |
Do not nest scope functions or chain more than two; name a val instead. it shadowing in
nested lambdas is a review flag.
Functions and extensions
- Default and named arguments replace builders and telescoping overloads; add
@JvmOverloadsonly when Java callers need the overloads. - Extension functions for behaviour on types you do not own, kept near their use (private
or internal) rather than in a global
Utils.ktdumping ground. - Single-expression functions for short bodies:
fun area() = width * height. - Top-level functions and
objectsingletons replace static utility classes; usecompanion objectonly for factory methods and constants tied to the class. const valfor compile-time constants;@JvmField/@JvmStaticonly for Java interop.
Collections and sequences
val totals = orders
.filter { it.status == Status.PAID }
.groupBy { it.customerId }
.mapValues { (_, list) -> list.sumOf { it.totalMinor } }
val firstLarge = hugeFile.useLines { lines -> // lazy, stops early
lines.map(::parseRecord).firstOrNull { it.size > 1_000_000 }
}
- Eager collection operators are fine for small and medium lists. Switch to
asSequence()for large data with several steps or early termination (first,take). - Prefer
associateBy,groupBy,partition,sumOf,maxByOrNull,chunked,windowedover manual loops with mutable accumulators. firstOrNull/singleOrNulloverfirstwhen absence is expected.
Errors
- Validate arguments with
require, state withcheck, impossible branches witherror("..."). - Kotlin has no checked exceptions; document thrown exceptions with
@Throwswhen Java callers need them. Result/runCatchingis for boundaries; inside coroutines it swallows cancellation (see$kotlin-coroutines). For domain errors, a sealed result type is clearer.
Strings and newer syntax
- Templates over concatenation;
trimIndent()for multiline literals. - Kotlin 2.2 stabilised
whenguards, non-localbreak/continuein inline lambdas, and multi-dollar interpolation ($$"""{"price": "$$amount"}""") for templates containing$. - Context parameters and other preview features need a compiler flag; do not introduce them unless the project already opts in.
Tooling
./gradlew detekt # static analysis; baseline existing issues, do not suppress new ones
./gradlew ktlintCheck # or spotless with ktlint, depending on the project
kotlin { explicitApi() } for libraries makes visibility and return types explicit on
public APIs.
Review checklist
- [ ] No new !!, lateinit used only for injected/initialised-before-use fields
- [ ] Platform types from Java annotated or handled as nullable
- [ ] when over sealed/enum without else; state modelled as sealed + data classes
- [ ] Scope functions chosen by the table; no nesting deeper than one
- [ ] Read-only types in public APIs; no exposed MutableList
- [ ] require/check with messages instead of IllegalArgumentException boilerplate
- [ ] detekt and ktlint clean
Gotchas
==is structural equality (callsequals);===is identity.- Data class
copyis shallow; nested mutable lists are shared between copies. lateinitthrowsUninitializedPropertyAccessException;::field.isInitializedchecks it, but needing that check means the field should be nullable or lazy.objectdeclarations are initialised lazily on first access and live for the process; they are global state, so inject them where tests need to replace them.- Default arguments in interfaces and overrides: overriding functions cannot redeclare defaults; the interface’s defaults apply.
Arrayequality is reference-based; data classes with array properties need customequalsorListinstead.
Verification
./gradlew build # compiles, tests, and runs configured checks
./gradlew detekt ktlintCheck
Grep the diff for !!, lateinit, else -> on sealed when, and MutableList in public
signatures; each occurrence needs a reason.
Report template
**Converted:** 6 Java classes -> Kotlin (OrderService, Money, 4 DTOs as data classes)
**Null safety:** 11 !! removed (elvis/early return/requireNotNull); 2 kept with proof comments
**State:** LoadState sealed interface; 4 when expressions now exhaustive without else
**Checks:** ./gradlew build green; detekt 0 new issues; ktlint clean What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Kotlin Idioms"
short_description: "Kotlin that stops reading like Java"
default_prompt: "$kotlin-idioms Convert these Java classes to idiomatic Kotlin, not just a syntax translation" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.