DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Scala: AI-Assisted Dev Guide 2026

Scala is the language where two developers on the same team write genuinely different dialects. One writes pure functional code with cats-effect IO, typeclass-derived instances, and Either[AppError, A] at every boundary. The other writes what looks like Java with better syntax — null returns, throw new RuntimeException, var counters, ArrayBuffer mutated in a for loop, Future chains with swallowed exceptions, and a deep class hierarchy rooted in an abstract BaseService. Both compile. Both pass tests. Only one of them survives contact with concurrency, error handling, and the production JVM.

Then you add an AI assistant.

Cursor and Claude Code were trained on fifteen years of Scala code that spans Scala 2.8 Java-in-Scala through modern Scala 3 with ZIO 2 and Cats-Effect 3. Ask for "a function that reads a user and returns their orders," and the AI picks a random slice of that history — you might get def getUser(id: Long): User that throws NotFoundException, or def getUser(id: Long): Future[Option[User]] with an implicit ExecutionContext leaking in from five files over, or def getUser(id: Long): IO[Either[AppError, User]] if you're lucky. It compiles either way. It tells you nothing about idiom, and it drags whatever patterns are dominant in your repo further from consistency with every prompt.

The fix is .cursorrules — one file in the repo that tells the AI what idiomatic Scala looks like in your codebase. Eight rules below, each with the failure mode, the rule that prevents it, and a before/after. Copy-paste .cursorrules at the end. Examples use Scala 3 syntax (given / using / enum) with a brief note where Scala 2 differs, Cats-Effect for effects, and standard immutable collections.


How Cursor Rules Work for Scala Projects

Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended for any non-trivial Scala project). For Scala I recommend modular rules so that effect-system conventions (IO vs Future) don't interfere with pure-library code and so the type-class conventions only fire on files that actually define instances:

.cursor/rules/
  scala-core.mdc        # immutability, Option/Either, for-comp, ADTs
  scala-types.mdc       # sealed trait + case class, enum, opaque types
  scala-effects.mdc     # cats-effect IO (or ZIO), no blocking in IO
  scala-collections.mdc # immutable.List/Vector/Map, no java.util, no var
  scala-typeclasses.mdc # given/using, no inheritance hierarchies for polymorphism
  scala-sbt.mdc         # -Werror, -Xfatal-warnings, scalafmt, scalafix
Enter fullscreen mode Exit fullscreen mode

Frontmatter controls activation: globs: ["**/*.scala", "**/*.sbt", "**/build.sbt", "**/project/*.scala"] with alwaysApply: false. Now the rules.


Rule 1: Immutability First — val Over var, Immutable Collections, No null

The single most common AI failure in Scala is "Java habits in Scala syntax." var count = 0 in a loop, ArrayBuffer mutated across function calls, null returned from a lookup, a mutable.Map passed between threads with no synchronization. Every one of these is a bug in a language whose whole point is that values don't change.

The rule:

Immutability is the default. Every value is a `val` unless there is a
specific, localized reason for `var` (tight-loop performance, or a
mutable cell inside a single function).

Collections default to scala.collection.immutable: List, Vector, Map,
Set. `mutable.*` is acceptable only inside a function body and never
crosses a method boundary.

Never return null. Never accept null as a valid input (use Option on
the type). When interop with Java returns nullable values, wrap at
the boundary: Option(javaCall()).

Transformations use map / flatMap / fold / foldLeft / collect  not
a while loop mutating a var. A `return` statement is a smell; rewrite
using a recursive helper, fold, or expression-oriented matching.

Prefer case class over class; prefer def over var; prefer val over def
when the computation is pure and cheap.

Forbidden at the API boundary:
  - Any type that is nullable in practice (java.util.Optional is
    tolerable only at the Java interop boundary).
  - Mutable collections as method parameters or return types.
  - Methods that take a function and mutate a captured variable.
Enter fullscreen mode Exit fullscreen mode

Bad — vars, null, mutable collection, Java-style loop:

class OrderService {
  def totalFor(userId: Long): Double = {
    val orders = repo.findByUser(userId) // returns null if none
    if (orders == null) return 0.0
    var total = 0.0
    val i = orders.iterator()
    while (i.hasNext) {
      total += i.next().total
    }
    total
  }
}
Enter fullscreen mode Exit fullscreen mode

Good — immutable values, Option at the boundary, expression-oriented:

final class OrderService(repo: OrderRepo):
  def totalFor(userId: UserId): Money =
    repo
      .findByUser(userId)      // returns List[Order] — empty means none
      .map(_.total)
      .foldLeft(Money.zero)(_ + _)
Enter fullscreen mode Exit fullscreen mode

No null. No var. The computation is a single expression. Money.zero makes the empty case obvious; foldLeft eliminates the loop.


Rule 2: Option, Either, Try — Not null and Not Thrown Exceptions

The second-biggest AI Scala failure is "exceptions as control flow." throw new IllegalArgumentException("not found") inside a business function, try/catch wrapping the caller to translate it back into a return value, null returned from a lookup, and a completely different dialect being used by the next file over. The JVM will happily execute it; the type system tells you nothing about which functions can fail; and "find everywhere this error can surface" becomes a stack-trace-grepping exercise.

The rule:

Failure is data. Fallible operations return:
  - Option[A]           value may be absent, no reason needed.
  - Either[E, A]        value may be absent with a reason; E is your
                          error ADT, never a String or Throwable.
  - Try[A]              used at the boundary with code that throws
                          (parsers, reflection, unsafe Java libs). Converted
                          to Either[E, A] at the earliest opportunity.
  - IO[A] / ZIO[R, E, A]  for effectful computations (see Rule 6).

Errors are a SEALED TRAIT (or Scala 3 `enum`) specific to the domain:

  enum OrderError:
    case InsufficientStock(itemId: ItemId)
    case PaymentDeclined(reason: String)
    case NotFound
    case Validation(field: String, message: String)

NEVER:
  - throw an exception from a business function. Exceptions are for
    bugs, boundary failures, and fatal JVM errors only.
  - Return Either[String, A] or Either[Throwable, A]  lose the type
    info and the pattern matcher can't help you.
  - Return null / Option[Null] / Option[Option[A]].
  - Swallow exceptions in a broad `catch NonFatal(_) =>`. If you catch,
    you translate to a typed error and return it.

.get on Option and .right.get on Either are forbidden in production
code. Use pattern matching, getOrElse, fold, or orElse.
Enter fullscreen mode Exit fullscreen mode

Bad — thrown exception, String error, no typed ADT:

def authorize(user: User, action: String): Boolean = {
  if (user == null) throw new IllegalArgumentException("no user")
  if (user.banned) throw new RuntimeException("banned")
  user.permissions.contains(action)
}
Enter fullscreen mode Exit fullscreen mode

Good — typed errors, composable returns, no exceptions:

enum AuthError:
  case Banned
  case Missing(permission: String)

def authorize(user: User, action: Permission): Either[AuthError, Unit] =
  if user.banned then Left(AuthError.Banned)
  else if user.permissions.contains(action) then Right(())
  else Left(AuthError.Missing(action.name))
Enter fullscreen mode Exit fullscreen mode

Callers match on AuthError exhaustively. The compiler catches the day somebody adds a new case and forgets a branch.


Rule 3: For-Comprehensions for Monadic Composition

Scala's for is sugar for flatMap / map / withFilter. AI-generated code often builds nested flatMap callbacks instead — which works but becomes illegible once you chain three steps. For-comprehensions turn the same computation into a straight line that reads like imperative code but composes across Option, Either, IO, Future, and any other monad.

The rule:

When composing two or more monadic operations of the SAME effect,
use a for-comprehension  not nested flatMap or nested case matching.

Structure:
  for
    a <- monadA
    b <- monadB(a)
    c <- monadC(b)
  yield build(a, b, c)

The generators (a, b, c) can be:
  - val bindings for pure values (a = expr, no <-).
  - `if` filters (works for Option/List; does NOT work for Either  use
    a helper that lifts a Boolean into Either[E, Unit]).

Mixing effects:
  - Do NOT mix Future and Option in one for-comprehension  they're
    different monads. Use a monad transformer (OptionT, EitherT) or
    lift early.
  - For Either[E, A] with short-circuit, all steps must yield
    Either[E, _] with the SAME E. Adjust with .left.map or flatMap
    as needed.

For IO / ZIO chains, the for-comprehension is the standard idiom.
Desugared flatMap is acceptable for 1-2 steps but reach for `for` at 3+.
Enter fullscreen mode Exit fullscreen mode

Bad — flatMap tower, one operation per arrow, hard to read:

def placeOrder(userId: UserId, items: List[ItemId]): Either[AppError, Order] =
  users.find(userId).flatMap { user =>
    inventory.reserve(items).flatMap { reservation =>
      payments.charge(user, reservation.total).flatMap { charge =>
        orders.insert(user, reservation, charge).map { order =>
          order
        }
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

Good — straight-line for-comprehension:

def placeOrder(userId: UserId, items: List[ItemId]): Either[AppError, Order] =
  for
    user        <- users.find(userId).toRight(AppError.NotFound)
    reservation <- inventory.reserve(items)
    charge      <- payments.charge(user, reservation.total)
    order       <- orders.insert(user, reservation, charge)
  yield order
Enter fullscreen mode Exit fullscreen mode

Same computation. Half the indentation. The error type is AppError throughout; any step returning a different error uses .leftMap(AppError.from) at its arrow.


Rule 4: Algebraic Data Types — Sealed Traits + Case Classes (or Scala 3 enum)

Scala's killer feature is algebraic data types: express a value as "one of these variants," and the compiler checks that every match handles every case. AI assistants often reach for class hierarchies with abstract class, virtual methods, and instanceof checks instead — which is Java-shaped code that loses the entire advantage. The ADT + pattern match is the single biggest leverage point in Scala codebases.

The rule:

Model variants as SEALED hierarchies + case classes/objects, or (Scala 3)
as `enum`. Match exhaustively.

Scala 3:
  enum PaymentMethod:
    case Card(last4: String, brand: CardBrand)
    case BankTransfer(iban: String)
    case Wallet(provider: WalletProvider)

Scala 2:
  sealed trait PaymentMethod
  object PaymentMethod:
    final case class Card(last4: String, brand: CardBrand) extends PaymentMethod
    final case class BankTransfer(iban: String) extends PaymentMethod
    final case class Wallet(provider: WalletProvider) extends PaymentMethod

Pattern match on the ADT; the compiler warns on missing cases. Turn
that warning into an error with -Wunused -Wnonexhaustive and
-Xfatal-warnings.

Guidelines:
  - All concrete ADT cases are `final` (to stop further inheritance).
  - The trait / base enum is `sealed` (so the compiler can see all cases).
  - Behavior that depends on the variant is a pattern match in a
    module, NOT virtual methods on each case  this keeps cases
    focused on data and lets new behaviors be added without changing
    the data definitions (expression problem; we're picking "easy to
    add operations" over "easy to add variants").
  - Prefer `enum` over `sealed trait` + case objects in Scala 3.
  - Use `opaque type` for domain wrappers (UserId, Money) to get
    type-safety without runtime boxing.

Forbidden:
  - `abstract class` with concrete methods that variant subclasses override
     that's a virtual-dispatch hierarchy, not an ADT.
  - `if x.isInstanceOf[...]`  pattern match instead.
  - Partial matches (missing a case) in production code without a
    compiler warning acknowledgement.
Enter fullscreen mode Exit fullscreen mode

Bad — class hierarchy with virtual dispatch and instanceof:

abstract class PaymentMethod {
  def describe: String
  def last4: String = ""
}
class Card(val number: String) extends PaymentMethod {
  override def describe = s"Card ending ${number.takeRight(4)}"
  override def last4 = number.takeRight(4)
}
class BankTransfer(val iban: String) extends PaymentMethod {
  override def describe = s"Bank: $iban"
}

def processorFor(pm: PaymentMethod): Processor = {
  if (pm.isInstanceOf[Card]) CardProcessor
  else if (pm.isInstanceOf[BankTransfer]) BankProcessor
  else throw new RuntimeException("unknown")
}
Enter fullscreen mode Exit fullscreen mode

Good — enum, exhaustive pattern match, opaque types:

opaque type Iban   = String
opaque type Last4  = String

enum PaymentMethod:
  case Card(last4: Last4, brand: CardBrand)
  case BankTransfer(iban: Iban)
  case Wallet(provider: WalletProvider)

def describe(pm: PaymentMethod): String = pm match
  case PaymentMethod.Card(last4, brand)  => s"$brand ending ${last4: String}"
  case PaymentMethod.BankTransfer(iban)  => s"Bank: ${iban: String}"
  case PaymentMethod.Wallet(p)           => s"Wallet: $p"

def processorFor(pm: PaymentMethod): Processor = pm match
  case _: PaymentMethod.Card         => CardProcessor
  case _: PaymentMethod.BankTransfer => BankProcessor
  case _: PaymentMethod.Wallet       => WalletProcessor
Enter fullscreen mode Exit fullscreen mode

Add a fourth payment method and the compiler lists every match that needs updating. No instanceof. No "unknown" branch that will someday fire at runtime.


Rule 5: Type Classes Over Inheritance for Polymorphism

When you need "the same operation, different types," the Java reflex is a base class or an interface. The Scala idiom is a type class — a trait parameterized by the type, with instances (given in Scala 3, implicit in Scala 2) that the compiler resolves. AI-generated code rarely uses type classes because the default training data is full of interface-heavy OOP. The result is a rigid hierarchy where adding a new type means editing a base class; a type class lets you add instances without touching the hierarchy at all.

The rule:

For polymorphism over types you don't control (stdlib types, Java
types, library types), use a TYPE CLASS rather than inheritance.

Scala 3:
  trait Show[A]:
    def show(a: A): String

  object Show:
    given Show[Int] with
      def show(a: Int) = a.toString

    given [A: Show]: Show[List[A]] with
      def show(xs: List[A]) = xs.map(summon[Show[A]].show).mkString("[", ",", "]")

  def display[A: Show](a: A): String = summon[Show[A]].show(a)

Scala 2:
  trait Show[A] { def show(a: A): String }
  object Show {
    implicit val intShow: Show[Int] = (a: Int) => a.toString
    implicit def listShow[A](implicit s: Show[A]): Show[List[A]] =
      xs => xs.map(s.show).mkString("[", ",", "]")
  }

Guidelines:
  - Instances go in the companion of the type class OR the companion
    of the data type  nowhere else. Keep implicit scope tight.
  - Derive instances when possible (Scala 3 `derives Show`; Shapeless
    in Scala 2) rather than hand-writing boilerplate.
  - Use Cats / Typelevel's existing type classes (Eq, Show, Functor,
    Monad) over rolling your own. Don't reinvent Semigroup.

Forbidden:
  - `implicit` imports at the call site to inject behavior  instances
    belong in canonical scope.
  - `abstract class` hierarchies used to simulate type classes  you've
    lost the open-for-extension benefit.
  - Global `implicit` vals with broad types (implicit ec: ExecutionContext
    at a global scope is the classic trap  inject it explicitly).
Enter fullscreen mode Exit fullscreen mode

Bad — inheritance hierarchy for a rendering capability:

abstract class Renderable {
  def render: String
}
class IntWrapper(i: Int) extends Renderable {
  override def render = i.toString
}
class OrderWrapper(o: Order) extends Renderable {
  override def render = s"Order(${o.id})"
}
def display(r: Renderable): String = r.render
Enter fullscreen mode Exit fullscreen mode

Any type that renders needs to be wrapped. Primitives can't be renderable. Third-party types need adapters.

Good — type class, instances in companions, no wrappers:

trait Render[A]:
  def render(a: A): String

object Render:
  given Render[Int] with
    def render(a: Int) = a.toString

  given Render[Order] with
    def render(o: Order) = s"Order(${o.id})"

  given [A: Render]: Render[List[A]] with
    def render(xs: List[A]) = xs.map(summon[Render[A]].render).mkString("[", ",", "]")

def display[A](a: A)(using r: Render[A]): String = r.render(a)
Enter fullscreen mode Exit fullscreen mode

display(42), display(order), display(List(order1, order2)) all work. Adding a Render[User] doesn't require editing anything except the Render companion or User's companion.


Rule 6: Effect Types — IO / ZIO Over Raw Future, No Blocking on the Main Pool

Future was Scala's effect type for a decade, and the AI's training data is full of it. But Future has structural problems: it's eager (starts as soon as it's created), it captures its ExecutionContext from implicit scope (which makes "what runs where" invisible), and it can't be cancelled. Modern production Scala uses Cats-Effect's IO or ZIO's ZIO[R, E, A]: referentially transparent, cancellable, with explicit scheduling.

The rule:

For new code, effect composition uses IO (cats-effect) or ZIO.
Future is acceptable at the boundary with libraries that return it
(akka-http, slick) and is converted to IO immediately (IO.fromFuture).

Rules with IO/ZIO:
  - No hidden implicit ExecutionContext. The effect runtime carries
    its own scheduler.
  - Blocking operations use IO.blocking { ... } (cats-effect) or
    ZIO.attemptBlocking. Never block on the compute pool.
  - Errors are typed: IO raises Throwable (wrap to Either[E, A] for
    domain errors); ZIO[R, E, A] makes E first-class.
  - Concurrency uses parMapN, parTraverse, Stream, Fiber  all with
    bounded parallelism.
  - Shutdown semantics are defined: Resource (cats-effect) or Scope
    (ZIO) for anything that needs cleanup.

Future-specific rules (if you must use it):
  - Every Future is created inside an enclosing function that takes
    ExecutionContext explicitly (using)  never as a global implicit.
  - Never Await.result in production code. Run at the edge of the
    world only.
  - Use Future.traverse with an explicit parallelism bound, not bare
    flat Map-over-list.
  - Never swallow failures in .recover { case _ => default } without
    at least logging.

Forbidden everywhere:
  - Thread.sleep in business logic. Use IO.sleep (which doesn't block).
  - scala.concurrent.blocking {} wrapping network calls  it's a hint
    to the default pool, not a separate pool. Use a dedicated EC or
    IO.blocking.
  - Bare Thread / new Runnable / ExecutorService in code. Use the
    effect library's scheduling.
Enter fullscreen mode Exit fullscreen mode

Bad — raw Future, global implicit EC, Await at the edge, swallowed failure:

import scala.concurrent.ExecutionContext.Implicits.global

def topCustomers(): Future[List[Customer]] = {
  customers.listAll().map { all =>
    all.filter(_.spend > 10000)
       .sortBy(-_.spend)
       .take(10)
  }.recover { case _ => Nil }
}

val result = Await.result(topCustomers(), 10.seconds) // blocks the caller
Enter fullscreen mode Exit fullscreen mode

Good — IO, explicit resource, typed error, bounded concurrency:

import cats.effect.*
import cats.syntax.all.*

def topCustomers(repo: CustomerRepo): IO[Either[AppError, List[Customer]]] =
  repo.listAll.attempt.map:
    case Right(all)   => Right(all.filter(_.spend > 10_000).sortBy(-_.spend).take(10))
    case Left(e: DbError) => Left(AppError.Persistence(e))
    case Left(e)      => Left(AppError.Unexpected(e.getMessage))

// At the edge of the world (main / HTTP handler):
object Main extends IOApp.Simple:
  def run: IO[Unit] =
    CustomerRepo.resource.use: repo =>
      topCustomers(repo).flatMap:
        case Right(customers) => IO.println(customers.mkString("\n"))
        case Left(err)        => IO.println(s"failed: $err").as(ExitCode.Error).void
Enter fullscreen mode Exit fullscreen mode

No global ExecutionContext. No blocking. The effect is lazy — you can compose topCustomers with retries, timeouts, or parMapN without evaluating anything.


Rule 7: Collections — Immutable by Default, Lazy with Care, No java.util

Scala has three collection hierarchies: scala.collection.immutable, scala.collection.mutable, and views/streams. AI-generated code picks at random from all three plus java.util (because Java interop is easy and the training data includes mixed codebases). The consequence is subtle correctness bugs — a mutable.Map passed to two threads, a LazyList held at head causing memory retention, a java.util.ArrayList in a public API that callers have to defensively copy.

The rule:

Collection choices:
  - Use scala.collection.immutable.{List, Vector, Map, Set} by default.
  - Use Vector for random access or large collections; List for head-heavy
    linked-list access.
  - Use LazyList for infinite or expensive streams, but NEVER hold a
    reference to the head  iterate and drop, or use fs2.Stream / ZStream.
  - Use mutable.* ONLY inside a function body and convert to immutable
    before returning.
  - Use Array only for JVM interop or numeric hot loops.
  - Do NOT expose java.util.{List, Map, Set} in public APIs. Wrap or
    convert at the boundary: javaList.asScala.toList.

Common pitfalls:
  - `.view` is lazy; materialize with `.toList` / `.toVector` before
    returning or storing.
  - `.par` (parallel collections) is deprecated in Scala 3 / moved to a
    library; prefer IO.parTraverse, ZIO.foreachPar, or fs2.Stream.
  - `.iterator` can be consumed only once  don't return one from a public
    method, return a `View` or a fresh-each-time function.

Performance: `List` prepends are O(1); appends are O(n). Use
`List#::` / `Vector#:+` appropriately, or build with `ListBuffer`
inside a function and convert at the end.

Equality: use `==` (structural) for value types. For primitive-heavy
code, use `java.util.Arrays.equals`. `.eq` (reference equality) is
rarely what you want.
Enter fullscreen mode Exit fullscreen mode

Bad — mutable buffer returned, view held, java.util leaking:

def topScorers(rounds: java.util.List[Round]): mutable.Buffer[Player] = {
  val buf = mutable.Buffer[Player]()
  val it = rounds.iterator()
  while (it.hasNext) {
    val r = it.next()
    if (r.score > 100) buf += r.player
  }
  buf
}

val lazy = players.view.filter(_.active) // never materialized — surprises later
Enter fullscreen mode Exit fullscreen mode

Good — immutable API, no java interop leakage, materialized before return:

def topScorers(rounds: List[Round]): List[Player] =
  rounds.collect { case Round(p, score) if score > 100 => p }

// Interop at the boundary
def fromJava(rounds: java.util.List[Round]): List[Round] =
  rounds.asScala.toList

// Active players, materialized once
val active: Vector[Player] = players.iterator.filter(_.active).toVector
Enter fullscreen mode Exit fullscreen mode

Callers can safely share the returned List. No hidden mutation. No accidentally-retained view.


Rule 8: Strict sbt — -Werror, Fatal Warnings, scalafmt, scalafix

A Scala compiler run that produces warnings is a Scala compiler run that's hiding bugs. Unused imports, missing exhaustive matches, deprecated APIs, implicit conversions that lose type information — every one of these is a warning until you treat it as an error. AI-generated code frequently introduces all of them because the compiler didn't stop the build. A strict sbt config makes the compiler an ally instead of a doorman.

The rule:

build.sbt baseline for every module:

  scalaVersion := "3.3.3"  // latest LTS
  scalacOptions ++= Seq(
    "-deprecation",
    "-feature",
    "-unchecked",
    "-Werror",                 // warnings are errors
    "-Wunused:all",
    "-Wvalue-discard",
    "-Ysafe-init",
    "-explain",
    "-source:future"           // opt into future-compat warnings
  )

For Scala 2 use equivalent flags plus the compiler plugin:
  - "-Xfatal-warnings"
  - "-Ywarn-unused"
  - "-Xlint:_"
  - wartremover plugin for deeper checks (Any, Null, Var).

Tooling in CI:
  - scalafmt    formatting, config committed, check in CI.
  - scalafix    semantic rewrites (OrganizeImports, ExplicitResultTypes).
  - sbt +test   all cross-compilations green.
  - mdoc        documentation examples are compiled and tested.

Library hygiene:
  - Pin Scala version (scalaVersion := "3.3.3", not latest).
  - Prefer libraryDependencySchemes := strict to catch minor-version
    incompatibilities.
  - Use sbt-dependency-check or sbt-snyk in CI.
  - Favor typelevel stack (cats, cats-effect, fs2, http4s, skunk,
    doobie) for effect-typed apps; play / akka for frameworks.

Project layout:
  - Business logic in `core/` with zero web/http dependencies.
  - Web adapter in `http/`, database adapter in `db/`.
  - Main module wires them together. Each module is independently
    testable.
Enter fullscreen mode Exit fullscreen mode

Bad — permissive scalac, no warnings enforced, Any-leaking API:

// build.sbt
scalaVersion := "3.3.0"
scalacOptions ++= Seq("-deprecation")

// elsewhere
def handle(input: Any): Any = input match
  case s: String => s.length
  case i: Int    => i * 2
  case _         => null   // null returned, Any accepted, Any returned
Enter fullscreen mode Exit fullscreen mode

Good — strict flags, no Any at the boundary, exhaustive match:

// build.sbt
scalaVersion := "3.3.3"
scalacOptions ++= Seq(
  "-deprecation", "-feature", "-unchecked",
  "-Werror", "-Wunused:all", "-Wvalue-discard",
  "-Ysafe-init", "-explain", "-source:future"
)

enum Input:
  case Text(s: String)
  case Number(i: Int)

def handle(in: Input): Int = in match
  case Input.Text(s)   => s.length
  case Input.Number(i) => i * 2
Enter fullscreen mode Exit fullscreen mode

Add Input.Bool(b: Boolean) and the build fails at every unexhaustive match. No Any. No null. No silent regressions.


The Complete .cursorrules File for Scala

Drop this into your repo root as .cursorrules, or split into .cursor/rules/*.mdc files. It's the consolidated version of every rule above plus the tooling defaults.

# Scala Cursor Rules

## Immutability
- Default to `val`; `var` only inside a function body for localized performance.
- scala.collection.immutable by default (List, Vector, Map, Set).
- Never return null; never accept null. Wrap Java interop with Option(...).
- mutable.* never crosses a method boundary.
- Prefer case class over class; prefer def over var.

## Errors
- Fallible operations return Option[A], Either[E, A], or IO/ZIO with typed errors.
- E is a sealed trait / enum specific to the domain — never String, never Throwable.
- Never throw from a business function.
- Never `catch NonFatal(_)` without translating to a typed error.
- .get on Option and .right.get on Either are forbidden in production.

## For-Comprehensions
- Use for { ... } for 2+ monadic steps over the same effect.
- Mix effects via OptionT / EitherT, not ad-hoc conversions in the middle.
- Adjust error types with `.leftMap` so all generators share an E.

## ADTs
- Model variants with sealed trait + case class or Scala 3 enum.
- All concrete cases are final; the trait/base is sealed.
- Behavior depending on variant is a pattern match in a module — not
  virtual methods per case.
- Use opaque type for domain wrappers (UserId, Money).
- No `x.isInstanceOf[...]`.

## Type Classes
- Polymorphism over external/stdlib types uses a type class, not inheritance.
- Instances live in the companion of the type class OR the data type — tight scope.
- Prefer Cats' Eq/Show/Functor/Monad over hand-rolled.
- No global implicit ExecutionContext; inject explicitly.
- Derive instances (derives Show, Codec) when possible.

## Effects
- New code uses IO (cats-effect) or ZIO for effect composition.
- Future is a boundary type only; convert to IO with IO.fromFuture.
- IO.blocking for blocking ops — never block the compute pool.
- parTraverse / parMapN with explicit concurrency bounds.
- Resource / Scope for anything needing cleanup.
- No Thread.sleep in business logic; use IO.sleep.

## Collections
- scala.collection.immutable by default.
- Vector for random access; List for head-heavy.
- LazyList: never hold the head.
- mutable.* only inside a function; convert before return.
- Do not expose java.util.* in public APIs; convert at the boundary.

## sbt and Tooling
- scalaVersion pinned (latest LTS).
- scalacOptions: -Werror, -Wunused:all, -Wvalue-discard, -Ysafe-init, -explain.
- scalafmt config committed; scalafmtCheckAll in CI.
- scalafix rules: OrganizeImports, ExplicitResultTypes, NoAutoTupling.
- mdoc for documentation that's compiled.
- libraryDependencySchemes := strict; CI dependency checks.

## Testing
- MUnit or ScalaTest with async: true where state allows.
- Property tests via ScalaCheck for data transformations.
- Effect tests with cats-effect-testing or zio-test.
- No shared mutable state in tests.
Enter fullscreen mode Exit fullscreen mode

Real Examples: AI-Generated Code Before and After Rules

Here's what changes in practice when the rules above are loaded into Cursor.

Example 1: "Write a function that loads a user, fetches their orders, and computes total spend."

Without rules — typical AI output:

import scala.concurrent.ExecutionContext.Implicits.global

def totalSpend(userId: Long): Future[Double] = {
  userRepo.find(userId).flatMap { userOpt =>
    userOpt match {
      case Some(user) =>
        orderRepo.byUser(user.id).map { orders =>
          var total = 0.0
          for (o <- orders) total += o.amount
          total
        }
      case None => Future.failed(new RuntimeException("not found"))
    }
  }.recover { case _ => 0.0 }
}
Enter fullscreen mode Exit fullscreen mode

Sin count: global implicit EC, Future.failed with a String wrapped in an exception, var accumulator, imperative for-loop, swallowed failure in .recover, no typed error, no return type on orderRepo.byUser, uses Double for money.

With rules in .cursorrules — same prompt, idiomatic output:

enum AppError:
  case UserNotFound
  case Persistence(cause: Throwable)

def totalSpend(userId: UserId)(using users: UserRepo, orders: OrderRepo)
    : IO[Either[AppError, Money]] =
  (for
    user   <- EitherT(users.find(userId).attempt.map {
                case Right(Some(u)) => Right(u)
                case Right(None)    => Left(AppError.UserNotFound)
                case Left(e)        => Left(AppError.Persistence(e))
              })
    orders <- EitherT(orders.byUser(user.id).attempt.map {
                case Right(os) => Right(os)
                case Left(e)   => Left(AppError.Persistence(e))
              })
  yield orders.map(_.amount).foldLeft(Money.zero)(_ + _)).value
Enter fullscreen mode Exit fullscreen mode

Typed error ADT. Money instead of Double. for over EitherT composes errors cleanly. No global EC; dependencies via using. No var, no imperative loop, no swallowed failure.

Example 2: "Process 1000 items in parallel, bounded to 8 at a time."

Without rules:

val futures = items.map(i => Future(process(i)))
val all = Future.sequence(futures)       // unbounded parallelism
val results = Await.result(all, Inf)     // blocks forever if any hangs
Enter fullscreen mode Exit fullscreen mode

With rules:

def processAll(items: List[Item]): IO[List[Result]] =
  items.parTraverseN(8)(item => IO.blocking(process(item)))
Enter fullscreen mode Exit fullscreen mode

Bounded at 8 concurrent. Blocking process runs on the blocking pool, not the compute pool. The whole thing is lazy — compose with timeouts or retries before running.


Get the Full Pack

These eight rules cover the highest-leverage Scala patterns where AI assistants consistently fail — the ones that turn pure functional code into Java-in-Scala and quiet bugs into production incidents. Drop them into .cursorrules and you'll see the difference on the very next prompt.

If you want the same depth for Rust, Go, Java, Kotlin, TypeScript, Python, React, Next.js, Elixir, and more — all the rules I've packaged from a year of refining Cursor configs across production JVM codebases — they're all at:

oliviacraft.lat

One pack. Twenty-plus languages and frameworks. Battle-tested rules with before/after examples for each. Stop fighting your AI assistant and start shipping idiomatic Scala on the first try.

Top comments (0)