DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Scala: 13 Rules That Make AI Write Idiomatic Functional Code

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 null when the user isn't found, instead of Option[User].
  • Three Future { … } blocks chained with .map / .flatMap and an Await.result at 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, when Order is obviously a closed sum of states (Draft, Submitted, Paid, Shipped, Cancelled).
  • A try { … } catch { case _: Throwable => default } that swallows everything, including the OutOfMemoryError you'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 stray String in scope into a UserId, 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
Enter fullscreen mode Exit fullscreen mode

Good:

def findUser(id: UserId): Option[User] =
  db.load(id)

findUser(UserId(42)).fold("anon")(_.name)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Good:

def label(s: PaymentStatus): String = s match
  case PaymentStatus.Pending  => "pending"
  case PaymentStatus.Captured => "captured"
  case PaymentStatus.Refunded => "refunded"
  case PaymentStatus.Failed   => "failed"
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

Good:

import org.scalacheck.Prop.forAll

property("parse(render(o)) == Right(o)") {
  forAll(orderGen) { (o: Order) =>
    parse(render(o)) == Right(o)
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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 Packhttps://oliviacraftlat.gumroad.com/l/skdgt

Top comments (0)