Cursor Rules for Kotlin: 6 Rules That Make AI Write Safe, Expressive Kotlin
If you use Cursor or Claude Code for Kotlin development, you've watched the AI generate code that looks like Java with a .kt extension. Nullable types used everywhere instead of sealed results. when expressions that forget exhaustive checks. Coroutines launched with GlobalScope. Extension functions that should be methods, or methods that should be extensions.
The fix isn't better prompting. It's better rules.
Here are 6 cursor rules for Kotlin that make your AI assistant write code that's idiomatic, null-safe, and takes full advantage of what makes Kotlin great. Each one includes a before/after example so you can see exactly what changes.
1. Enforce Sealed Classes for Domain Results — Ban Nullable Return Types
Without this rule, AI returns null to represent failure. You lose error context, ?.let chains spread like weeds, and the compiler can't help you handle all cases.
The rule:
Use sealed classes or sealed interfaces to model operation results with
distinct success and failure states. Never return null to represent failure.
Every when-expression over a sealed type must be exhaustive (no else branch).
Bad — what the AI generates without the rule:
fun getUser(id: Long): User? {
val user = userRepository.findById(id)
return user
}
// Caller
val user = getUser(id)
if (user != null) {
sendEmail(user.email)
} else {
// What went wrong? No idea.
log.error("User not found")
}
Good — what the AI generates with the rule:
sealed interface UserResult {
data class Found(val user: User) : UserResult
data class NotFound(val id: Long) : UserResult
data class DatabaseError(val cause: Throwable) : UserResult
}
fun getUser(id: Long): UserResult {
return try {
userRepository.findById(id)
?.let { UserResult.Found(it) }
?: UserResult.NotFound(id)
} catch (e: DataAccessException) {
UserResult.DatabaseError(e)
}
}
// Caller — compiler forces you to handle every case
when (val result = getUser(id)) {
is UserResult.Found -> sendEmail(result.user.email)
is UserResult.NotFound -> log.warn("User ${result.id} not found")
is UserResult.DatabaseError -> log.error("DB failed", result.cause)
}
Adding a new failure case to the sealed class causes a compile error at every when expression that doesn't handle it. The compiler becomes your reviewer.
2. Enforce Structured Concurrency — Ban GlobalScope
AI reaches for GlobalScope.launch because it's the simplest coroutine example in every tutorial. But GlobalScope coroutines are fire-and-forget: they leak, they ignore cancellation, and they survive past the lifecycle of the component that started them.
The rule:
Never use GlobalScope. Always launch coroutines within a structured scope:
viewModelScope, lifecycleScope, or a CoroutineScope tied to a component's lifecycle.
Use supervisorScope when child failures should not cancel siblings.
Always handle exceptions with CoroutineExceptionHandler or try/catch inside the coroutine.
Bad — GlobalScope, fire and forget:
class OrderService {
fun processOrder(order: Order) {
GlobalScope.launch {
paymentGateway.charge(order)
notificationService.send(order.userId, "Order processed")
}
}
}
Good — structured scope with error handling:
class OrderService(
private val paymentGateway: PaymentGateway,
private val notificationService: NotificationService,
private val scope: CoroutineScope,
) {
fun processOrder(order: Order) {
scope.launch {
supervisorScope {
val payment = async { paymentGateway.charge(order) }
payment.await()
launch { notificationService.send(order.userId, "Order processed") }
}
}
}
}
Now when the service is destroyed, all in-flight coroutines are cancelled. Child failures are contained by supervisorScope. The scope is injected, so tests can use TestScope for deterministic execution.
3. Enforce Extension Functions for Cross-Cutting Utilities — Ban Util Classes
AI generates StringUtils, DateUtils, and CollectionUtils classes full of static methods. This is Java thinking. Kotlin's extension functions let you add behavior directly to types without inheritance or wrapper classes.
The rule:
Use extension functions instead of utility classes for operations on existing types.
Place extensions in a file named after the extended type (StringExtensions.kt).
Keep extensions pure — no side effects, no accessing external mutable state.
Use extension properties for computed values that feel like natural properties.
Bad — Java-style utility class:
object StringUtils {
fun toSlug(input: String): String {
return input.lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.replace(Regex("\\s+"), "-")
.trim('-')
}
fun truncate(input: String, maxLength: Int): String {
return if (input.length > maxLength) input.take(maxLength) + "..." else input
}
}
// Caller
val slug = StringUtils.toSlug(title)
Good — extension functions:
// StringExtensions.kt
fun String.toSlug(): String =
lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.replace(Regex("\\s+"), "-")
.trim('-')
fun String.truncate(maxLength: Int): String =
if (length > maxLength) take(maxLength) + "..." else this
// Caller
val slug = title.toSlug()
The function reads like a natural property of the type. Autocomplete discovers it. No import of a utility class needed — just the extension.
4. Enforce Data Classes for Value Types — Ban Plain Classes for Data
Without this rule, AI generates plain classes for data holders. You lose automatic equals, hashCode, copy, and destructuring — the features that make Kotlin data handling concise and safe.
The rule:
Use data classes for any type whose primary purpose is holding data.
Data classes must have at least one val parameter in the primary constructor.
Use copy() for creating modified instances — never mutate data class properties.
Use destructuring declarations in lambdas when it improves readability.
Bad — plain class, manual boilerplate:
class UserProfile(
var name: String,
var email: String,
var role: Role
) {
fun updateRole(newRole: Role) {
this.role = newRole
}
override fun equals(other: Any?): Boolean {
if (other !is UserProfile) return false
return name == other.name && email == other.email && role == other.role
}
override fun hashCode() = Objects.hash(name, email, role)
}
Good — data class with copy:
data class UserProfile(
val name: String,
val email: String,
val role: Role,
)
// Update creates a new instance — original unchanged
val promoted = userProfile.copy(role = Role.ADMIN)
// Destructuring in lambdas
users.forEach { (name, email, role) ->
log.info("$name ($email) — $role")
}
Immutable by default. equals and hashCode are automatic and correct. copy() makes updates explicit. Destructuring makes collection operations readable.
5. Enforce Kotlin-Idiomatic Null Safety — Ban !! (Non-Null Assertion)
The !! operator is Kotlin's escape hatch from null safety. AI uses it constantly because it makes the compiler stop complaining. But every !! is a potential NullPointerException — the exact thing Kotlin was designed to prevent.
The rule:
Never use the !! operator. If a value might be null, handle it explicitly.
Use ?.let for conditional execution on nullable values.
Use ?: (Elvis) for providing defaults or throwing meaningful exceptions.
Use requireNotNull() or checkNotNull() with a message when a null truly indicates a bug.
Bad — !! hiding potential crashes:
fun processPayment(userId: Long) {
val user = userRepository.findById(userId)!!
val card = user.defaultPaymentMethod!!
val result = paymentGateway.charge(card.token!!, amount)
notificationService.send(user.email!!, result.message!!)
}
Good — explicit null handling:
fun processPayment(userId: Long) {
val user = userRepository.findById(userId)
?: throw UserNotFoundException(userId)
val card = user.defaultPaymentMethod
?: throw PaymentException("No payment method for user $userId")
val result = paymentGateway.charge(
token = card.token ?: throw PaymentException("Card has no token"),
amount = amount,
)
result.message?.let { message ->
notificationService.send(user.email, message)
}
}
Every null case has a meaningful handler. When something fails, the exception tells you exactly what was null and why it matters. Zero NullPointerException surprise crashes.
6. Enforce Scope Functions Correctly — Ban Misuse of apply/also/let/run
AI uses scope functions almost randomly — apply where also belongs, let chains three levels deep, run used just to avoid writing this.. Each scope function has a specific purpose, and misuse destroys readability.
The rule:
Use apply for object configuration (returns the object).
Use also for side effects like logging (returns the object).
Use let for transforming nullable values (returns the lambda result).
Use run for computing a result from an object's context.
Never nest scope functions more than one level deep.
Bad — scope function chaos:
val user = User().apply {
name = "Alice"
email = "alice@example.com"
}.let {
it.also {
logger.info("Created user: ${it.name}")
}.apply {
role = Role.USER
}.run {
userRepository.save(this)
}
}
Good — each scope function used for its purpose:
val user = User().apply {
name = "Alice"
email = "alice@example.com"
role = Role.USER
}.also { logger.info("Created user: ${it.name}") }
val savedUser = userRepository.save(user)
apply configures the object. also logs as a side effect. The save is a separate statement because it's a separate operation. No nesting, no confusion about what this or it refers to.
Put These Rules to Work
These 6 rules cover the patterns where AI coding assistants fail most often in Kotlin projects. Add them to your .cursorrules or CLAUDE.md and the difference is immediate — fewer review comments, idiomatic code from the first generation, and less time rewriting AI output.
I've packaged these rules (plus 44 more covering coroutines, Ktor, Compose, and multiplatform patterns) into a ready-to-use rules pack: Cursor Rules Pack v2
Drop it into your project directory and stop fighting your AI assistant.
Top comments (0)