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. 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. 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. 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. 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 _ 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. 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. 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. 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. 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. 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. 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 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. 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. 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
