CLAUDE.md for Scala: 13 Rules That Make AI Write Idiomatic Functional Code
You ask Claude to "add a Subscription service that calls Stripe" inside your Scala codebase, and you get back something that compiles cleanly and is still wrong:
- A function that returns
nullwhen the user isn't found, instead ofOption[User]. - Three
Future { … }blocks chained with.map/.flatMapand anAwait.resultat the bottom — inside a service that's otherwise running on cats-effect. - A
class Order(var status: String, var total: Double)with seven mutable fields, whenOrderis obviously a closed sum of states (Draft,Submitted,Paid,Shipped,Cancelled). - A
try { … } catch { case _: Throwable => default }that swallows everything, including theOutOfMemoryErroryou'd actually want to crash on. - A
def transfer(from: String, to: String, amount: Double)that takes the world's three most ambiguous primitives and trusts that callers pass them in the right order. - A
given Conversion[String, UserId] = UserId(_)that silently turns every strayStringin scope into aUserId, because the model saw an "implicit conversions are convenient" example and ran with it.
The model isn't lazy. It's been trained on twenty years of mixed Scala — half of it Scala-as-better-Java from before cats-effect and ZIO existed, half of it heavyweight FP showing off. The median is closer to Java with semicolons elided than to the language Scala 3 actually is in 2026.
A CLAUDE.md at the root of your project drags it forward to where you actually live — Scala 3 with cats-effect 3 or ZIO 2, total functions, sealed sums, typed errors, and Resource-managed lifecycles.
Here are 13 rules I drop into every Scala project. Each one closes a class of bug AI assistants generate by default.
Rule 1 — No null, Ever (Use Option / Either / Try)
Why: null is the original Scala bug that wasn't supposed to exist. Anywhere a function might return "no value", AI tools default to null because that's what Java does and the training set is mostly Java-flavored Scala. The fix is structural: the absence is in the type, the compiler enforces handling, and pattern-match exhaustiveness gives you the rest.
Bad:
def findUser(id: Long): User =
if (db.exists(id)) db.load(id) else null
val name = findUser(42).name // NPE waiting to happen
Good:
def findUser(id: UserId): Option[User] =
db.load(id)
findUser(UserId(42)).fold("anon")(_.name)
Where the third-party graph allows it, enable -Yexplicit-nulls (Scala 3) so reference types are non-nullable by default and nullable types must be spelled String | Null. CI rejects PRs that introduce null literals or _ != null checks outside designated Java-interop modules.
Rule for CLAUDE.md:
No null in domain code. Use Option[A] for absence, Either[E, A] for typed errors,
Try[A] only at Java-interop boundaries (immediately converted to Either at the edge).
The only acceptable null is wrapped in Option(javaCall()) on the same line.
Rule 2 — val By Default, var On Review Only
Why: Scala makes val and var equally easy to type, so AI assistants use them interchangeably — except var lets bugs escape your reasoning the moment a fiber, an actor, or a concurrent test touches a shared field.
Bad:
class Counter:
var count: Int = 0
def increment(): Unit = count += 1 // race-y across fibers
def get: Int = count
Good:
import cats.effect.{IO, Ref}
class Counter(state: Ref[IO, Int]):
def increment: IO[Unit] = state.update(_ + 1)
def get: IO[Int] = state.get
object Counter:
def make: IO[Counter] = Ref.of[IO, Int](0).map(Counter(_))
Local var for a tight loop is acceptable when the alternative is contortion. Field var on a mutable shared object is a race waiting on the first parallel test.
Rule for CLAUDE.md:
val by default. var requires a comment explaining why and a thread-safety story.
Constructor parameters of case classes are always val. Shared mutable state lives
behind Ref[IO, A] / Ref[ZIO, A] / AtomicReference, never a plain field.
Rule 3 — case class for Data, enum for Sums, class Only When Behavior Dominates
Why: AI tools default to class because Java does. In Scala, class is the wrong choice for data — you lose structural equality, you lose pattern matching, you lose copy, you lose immutability by default. A domain object is almost always a case class or, for closed sums, a Scala 3 enum.
Bad:
class Order(var status: String, var total: Double):
def isPaid: Boolean = status == "paid"
def isShipped: Boolean = status == "shipped"
Good (Scala 3):
enum Order:
case Draft(items: List[Item])
case Submitted(items: List[Item], total: Money)
case Paid(items: List[Item], total: Money, paidAt: Instant)
case Shipped(items: List[Item], total: Money, paidAt: Instant, shippedAt: Instant)
case Cancelled(reason: CancellationReason)
def fulfilment(o: Order): IO[Unit] = o match
case _: Order.Draft => IO.unit
case s: Order.Submitted => paymentService.charge(s.total).void
case p: Order.Paid => warehouse.dispatch(p).void
case _: Order.Shipped => IO.unit
case _: Order.Cancelled => IO.unit
The compiler now tells you the day someone adds a new state but forgets to update fulfilment. Stringly-typed status fields lose that for free.
Rule for CLAUDE.md:
Domain types are case class (data) or Scala 3 enum (sealed sums).
Plain class only for stateful services and resource owners — never a data carrier.
Status / kind / type fields modelled as enum cases, not String.
Rule 4 — Effects in IO / ZIO, Not Raw Future
Why: Future runs eagerly, doesn't compose with cancellation, and forces an ExecutionContext to be threaded everywhere. IO[A] and ZIO[R, E, A] are values you can compose, retry, parallelize, and cancel — and they only run when something at the boundary asks them to.
Bad:
def chargeAndEmail(o: Order): Future[Unit] =
for
_ <- payments.charge(o.total)
_ <- mailer.send(o.user, "Receipt", render(o))
yield ()
Await.result(chargeAndEmail(order), 30.seconds)
Good:
def chargeAndEmail(o: Order): IO[Unit] =
for
_ <- payments.charge(o.total)
_ <- mailer.send(o.user, "Receipt", render(o))
yield ()
object App extends IOApp.Simple:
def run: IO[Unit] = chargeAndEmail(order).timeout(30.seconds)
Pick one effect type per module — cats-effect or ZIO, never both. Cross-module conversion lives in a single integration file.
Rule for CLAUDE.md:
Effects return IO[A] or ZIO[R, E, A]. Future is reserved for boundaries with
libraries that demand it (Akka HTTP, Slick, legacy code), converted at the edge.
One effect type per module — no mixing IO and ZIO in the same codebase.
Await.result / IO.unsafeRunSync only at IOApp.run / ZIOAppDefault.run / test setup.
Rule 5 — Resource[IO, A] / ZIO.scoped for Anything That Needs Cleanup
Why: A DB pool, an HTTP client, a Kafka consumer, a file handle, a thread pool — all of these need to be released on every exit path, including cancellation. try-finally doesn't honour cancellation; Resource does.
Bad:
def processFile(path: Path): IO[Unit] =
IO(Files.newInputStream(path)).flatMap { in =>
IO(in.readAllBytes()).flatMap(parse).flatMap { result =>
store(result)
} >> IO(in.close()) // skipped on error or cancellation
}
Good:
def inputStream(path: Path): Resource[IO, InputStream] =
Resource.make(IO(Files.newInputStream(path)))(in => IO(in.close()))
def processFile(path: Path): IO[Unit] =
inputStream(path).use { in =>
IO(in.readAllBytes()).flatMap(parse).flatMap(store)
}
Rule for CLAUDE.md:
Anything that needs cleanup (DB pool, HTTP client, Kafka consumer, file handle,
thread pool) is allocated through Resource[IO, A] / ZIO.scoped. try-finally
around effectful code is forbidden — cancellation does not respect it.
Rule 6 — Typed Errors with Either[Error, A] / ZIO[R, E, A]
Why: Throwable is an unchecked union of "the user typed a bad email" and "the JVM is out of metaspace". Treating those identically is how production logs become useless. Domain errors are an enum; the type signature tells you what can go wrong.
Bad:
def signup(req: SignupRequest): IO[User] =
validate(req).flatMap(repo.insert).recover {
case _: Throwable => User.empty // 🤡
}
Good:
enum SignupError:
case EmailTaken(email: Email)
case InvalidEmail(raw: String)
case PasswordTooWeak
def signup(req: SignupRequest): IO[Either[SignupError, User]] =
EitherT(validate(req)).flatMap(r => EitherT(repo.insert(r))).value
recover { case _ => default } is a code smell; pattern-match the specific failure types you can handle and let the rest propagate.
Rule for CLAUDE.md:
Domain errors are an enum / sealed trait, never a String or generic Exception.
Functions that can fail return Either[E, A] or ZIO[R, E, A]. recover blocks
match specific cases — never case _: Throwable.
Rule 7 — for-Comprehensions for Sequencing, traverse for Collections
Why: AI tools generate xs.map(f).map(_.toFuture).flatMap(Future.sequence) chains. Cats / ZIO Prelude give you traverse which says "apply this effectful function to each element and collect the results" in one method.
Bad:
val results: IO[List[Order]] =
IO.parTraverseN(8)(ids)(id => fetchOrder(id)).flatMap { list =>
IO(list.flatten) // implies fetchOrder returned Option, masking errors
}
Good:
val results: IO[List[Order]] =
ids.traverse(fetchOrder) // sequential
val parallel: IO[List[Order]] =
ids.parTraverseN(8)(fetchOrder) // bounded parallelism
val partial: IO[List[Either[FetchError, Order]]] =
ids.traverse(id => fetchOrder(id).attempt.map(_.leftMap(FetchError.from)))
Rule for CLAUDE.md:
for-comprehensions for sequencing effects. xs.traverse(f) / xs.parTraverseN(n)(f)
for collections of effects — never Future.sequence(xs.map(f)) or hand-rolled
flatMap + sequence. CI rejects those patterns where traverse exists.
Rule 8 — given Over implicit (Scala 3), and using for Context
Why: Scala 3 split implicits into orthogonal pieces — given for instances, using for context parameters, extension for methods. The legacy implicit keyword is a 2.13 fallback. AI tools mix them because the training set is half-and-half. Pick the new syntax and stick to it.
Bad (Scala 3 with 2.13 habits):
implicit val showUser: Show[User] = Show.show(_.name)
implicit class StringOps(val s: String) extends AnyVal:
def slugify: String = s.toLowerCase.replaceAll("\\s+", "-")
implicit def stringToUserId(s: String): UserId = UserId(s.toLong) // smell
Good:
given Show[User] = Show.show(_.name)
extension (s: String)
def slugify: String = s.toLowerCase.replaceAll("\\s+", "-")
// no implicit conversion — make the call explicit:
def parseUserId(s: String): Either[InvalidUserId, UserId] =
s.toLongOption.toRight(InvalidUserId(s)).map(UserId(_))
Rule for CLAUDE.md:
Scala 3 syntax: given for typeclass instances, using for context parameters,
extension for methods. Implicit conversions (given Conversion[A, B]) are a smell —
make conversion explicit. derives for canonical typeclass derivations.
Rule 9 — Newtypes for Primitive Obsession
Why: A def transfer(from: String, to: String, amount: Double) has three primitive parameters and three places callers can swap them. The compiler can't help. Newtypes — opaque type in Scala 3, AnyVal value class in 2.13 — give you compile-time enforcement at zero runtime cost.
Bad:
def transfer(from: String, to: String, amount: Double): IO[Receipt]
transfer(targetAccountId, sourceAccountId, amount) // silently wrong
Good (Scala 3):
opaque type AccountId = Long
object AccountId:
def apply(l: Long): AccountId = l
extension (a: AccountId) def value: Long = a
case class Money(amount: BigDecimal, currency: Currency)
def transfer(from: AccountId, to: AccountId, amount: Money): IO[Receipt]
transfer(targetAccountId, sourceAccountId, money) // compile error if swapped
Rule for CLAUDE.md:
opaque type / AnyVal newtypes for IDs, codes, and domain primitives.
A def transfer(from: String, to: String, amount: Double) is a primitive-obsession
bug — replace with AccountId, Money, etc. Compile-time enforcement, no runtime cost.
Rule 10 — Pattern Matching Is Exhaustive (Sealed / enum)
Why: Sealed hierarchies and Scala 3 enums give the compiler exhaustiveness checks for free — turn warnings into errors and the compiler tells you the day someone adds a state but forgets a transition. case _ => wildcards on closed sums silence that signal forever.
Bad:
enum PaymentStatus:
case Pending, Captured, Refunded, Failed
def label(s: PaymentStatus): String = s match
case PaymentStatus.Pending => "pending"
case PaymentStatus.Captured => "captured"
case _ => "other" // hides the day Refunded ships
Good:
def label(s: PaymentStatus): String = s match
case PaymentStatus.Pending => "pending"
case PaymentStatus.Captured => "captured"
case PaymentStatus.Refunded => "refunded"
case PaymentStatus.Failed => "failed"
Rule for CLAUDE.md:
Pattern matches on sealed / enum types are exhaustive. Wildcards (case _ =>) only
for genuinely open universes (e.g. Throwable). -Wnonunit-statement, -Werror,
-Wunused:all in scalacOptions so warnings block CI.
Rule 11 — Validation with Validated / EitherNec, Not Either Alone
Why: Either is fail-fast — the first error short-circuits the rest. For form validation, batch import, or anything where you want to show the user all errors at once, accumulate them with Validated (cats) or EitherNec.
Bad:
def validate(req: SignupRequest): Either[String, Signup] =
for
email <- validEmail(req.email)
pwd <- validPassword(req.password)
name <- validName(req.name)
yield Signup(email, pwd, name)
// User sees one error, fixes it, sees the next, etc.
Good:
import cats.data.ValidatedNec
import cats.syntax.all.*
def validate(req: SignupRequest): ValidatedNec[FieldError, Signup] =
(validEmail(req.email), validPassword(req.password), validName(req.name))
.mapN(Signup.apply)
// All field errors collected at once, returned to the form together.
Rule for CLAUDE.md:
Use Validated / EitherNec when accumulating multiple errors (form validation,
batch import). Either is fail-fast and is wrong for "show me everything that's wrong"
flows. Don't sequence a List[Either[E, A]] by hand.
Rule 12 — Property-Based Tests Where Inputs Are Non-Trivial
Why: Example-based tests miss boundary conditions every time. Anything with non-trivial input space — parsers, codecs, math, state machines, ADT round-trips — gets a property test, not just example tests.
Bad:
test("parser round-trips") {
assertEquals(parse(render(Order.draft)), Right(Order.draft))
assertEquals(parse(render(Order.paid)), Right(Order.paid))
}
Good:
import org.scalacheck.Prop.forAll
property("parse(render(o)) == Right(o)") {
forAll(orderGen) { (o: Order) =>
parse(render(o)) == Right(o)
}
}
Rule for CLAUDE.md:
ScalaCheck (or hedgehog) property tests for parsers, codecs, math, state machines,
and ADT round-trips. Example tests are fine for happy paths, not as the only coverage.
Rule 13 — Compiler Flags as Errors, Format and Lint in CI
Why: Scala has spent a decade adding diagnostics that catch real bugs (-Wunused, -Wvalue-discard, -Wnonunit-statement). Most repos disable them because the warnings are noisy on day one. The fix is to enable them, fix the noise, and treat warnings as errors from day two.
Rule for CLAUDE.md:
scalacOptions ++= Seq(
"-deprecation", "-feature", "-unchecked",
"-Wunused:all", "-Wvalue-discard", "-Wnonunit-statement",
"-Werror", "-source:future"
)
CI runs:
sbt clean compile // -Werror gates warnings
sbt scalafmtCheckAll // formatting
sbt "scalafix --check" // lint / rewrites
sbt test // unit + property + integration
sbt scoverage:report // coverage trend, not a hard 100% gate
Pre-commit hook runs scalafmt locally so the CI run is a no-op.
Why This Is Worth Doing Once
Every rule above traces to a real production bug from an AI-generated PR. A null returned from a "user lookup" endpoint that crashed the whole batch import an hour into the run. A Future-based service that mostly worked but lost cancellation-safety the day someone added a request timeout. An Order with a var status: String whose value depended on whichever fiber wrote last. A try { … } catch { case _ => … } that swallowed the OutOfMemoryError and let the JVM hobble on for another six minutes before crashing in a place that had nothing to do with the bug.
You can keep catching these in review forever. Or you can write a CLAUDE.md, drop it at the repo root, and stop seeing 80% of them.
The 13 rules above are a starting point — the full pack has 50+ production-tested rules covering Scala, Modern C++, Rust, Go, TypeScript, React, Vue, Django, FastAPI, Postgres, Kubernetes, Docker, and more.
Free Scala gist with all 3 rules → https://gist.github.com/oliviacraft/50d90ac9abf778cf888bb9bf81eed549
Full CLAUDE.md Rules Pack → https://oliviacraftlat.gumroad.com/l/skdgt
Top comments (0)