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
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.
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
}
}
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)(_ + _)
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.
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)
}
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))
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+.
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
}
}
}
}
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
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.
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")
}
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
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).
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
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)
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.
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
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
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.
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
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
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.
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
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
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.
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 }
}
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
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
With rules:
def processAll(items: List[Item]): IO[List[Result]] =
items.parTraverseN(8)(item => IO.blocking(process(item)))
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:
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)