DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Kotlin: 6 Rules That Make AI Write Safe, Expressive Kotlin

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).
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Bad — GlobalScope, fire and forget:

class OrderService {
    fun processOrder(order: Order) {
        GlobalScope.launch {
            paymentGateway.charge(order)
            notificationService.send(order.userId, "Order processed")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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") }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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!!)
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)