DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Scala: 13 Rules That Make AI Write Idiomatic, Type-Safe Scala

Scala has a well-known problem: it can be written in many styles, from Java-with-semicolons to pure functional with category theory abstractions. AI assistants without explicit guidance default to the most common patterns in their training data — which is often Java-style Scala from older codebases.

The result: mutable variables, null checks, raw Future.map chains without proper error handling, and classes where case classes belong.

A CLAUDE.md file tells the AI which Scala you're writing. Here are the 13 rules that matter most.


Rule 1: Scala version and style target

Scala version: 3.x (Scala 3 syntax — no Scala 2 `implicit` keyword, use `given`/`using`).
Style: functional-first. Immutable by default. Effects tracked explicitly.
Framework: [Akka / ZIO / Cats Effect / Play — specify yours]
Build: sbt with explicit dependency versions pinned in build.sbt.
Enter fullscreen mode Exit fullscreen mode

Scala 3 changed the syntax for implicits, extension methods, and type classes significantly. AI trained on pre-Scala 3 material generates Scala 2 syntax. Make the version explicit.


Rule 2: Case classes for data — not plain classes

Use case classes for all data transfer objects, domain models, and value objects:
  `case class User(id: UserId, name: String, email: Email)`

Benefits the AI must leverage:
- Automatic `equals`, `hashCode`, `copy`, `toString`
- Pattern matching support
- Immutability by default
- `unapply` for destructuring

Use `@derive` (Scala 3) for additional typeclass derivation (Show, Codec, Schema).
Use `opaque type` for type-safe wrappers: `opaque type UserId = Long`.
Enter fullscreen mode Exit fullscreen mode

AI often generates plain classes or Java-style beans for domain data. Case classes are the idiomatic Scala choice and unlock pattern matching throughout the codebase.


Rule 3: Pattern matching — exhaustive and expressive

Use pattern matching as the primary dispatch mechanism:

  response match
    case Success(user) => process(user)
    case Failure(e: NotFoundException) => notFound(e.message)
    case Failure(e) => internalError(e)

Rules:
- Always handle all cases — enable `-Wnonexhaustive-match` compiler warning
- Use guard clauses in patterns: `case user if user.active => ...`
- Destructure deeply: `case User(id, _, Email(addr)) => ...`
- Prefer `match` expressions that return values over `match` with side effects
Enter fullscreen mode Exit fullscreen mode

Pattern matching in Scala is more powerful than in most languages — AI underuses it. Exhaustive matching with compiler warnings is a major correctness tool.


Rule 4: Option, Either, Try — no null, no exceptions for flow

Error and absence handling:
- `Option[A]` for values that may be absent — never `null`
- `Either[Error, A]` for operations that can fail with a meaningful error
- `Try[A]` only at the boundary with exception-throwing Java libraries
- Never throw exceptions for business logic — use `Left(error)` or `Option.empty`
- Chain with `map`, `flatMap`, `fold`, `getOrElse`, `recover`

For complex chains: use for-comprehensions over nested flatMap calls.
Enter fullscreen mode Exit fullscreen mode

null and exceptions as control flow are Java habits that leak into Scala. AI without guidance uses them. Option/Either with for-comprehensions is idiomatic.


Rule 5: For-comprehensions over nested flatMap

Use for-comprehensions for sequential monadic operations:

  for
    user    <- findUser(id)
    account <- findAccount(user.accountId)
    _       <- validateBalance(account, amount)
  yield Payment(user, account, amount)

Not:
  findUser(id).flatMap(user =>
    findAccount(user.accountId).flatMap(account =>
      validateBalance(account, amount).map(_ => Payment(user, account, amount))
    )
  )

For-comprehensions work with any monad: Option, Either, Future, IO, ZIO.
Enter fullscreen mode Exit fullscreen mode

For-comprehensions are syntactic sugar for flatMap/map chains. They're dramatically more readable for sequential operations. AI often generates nested lambda chains.


Rule 6: Immutability — val over var, immutable collections

Immutability rules:
- `val` by default — `var` only when mutation is genuinely required and documented
- Immutable collections: `List`, `Vector`, `Map`, `Set` from `scala.collection.immutable`
- Never import `scala.collection.mutable` without an explicit comment explaining why
- Use `copy` on case classes instead of mutating: `user.copy(name = "Alice")`
- Prefer `foldLeft`/`foldRight` over accumulator variables

If you need a mutable cell: use `Ref` (ZIO), `IORef` (Cats Effect), or `AtomicReference`.
Enter fullscreen mode Exit fullscreen mode

Mutable state is the source of most concurrency bugs. Scala's type system enables functional immutability — AI needs to be told to use it.


Rule 7: Type classes — given/using, not implicit magic

Scala 3 type class pattern:

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

  given Show[User] with
    def show(u: User) = s"User(${u.id}, ${u.name})"

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

Rules:
- Use `given` for instances, `using` for parameters (not `implicit`)
- Derive type class instances where possible: `derives Show, Encoder, Decoder`
- Keep given instances in companion objects or dedicated `given` files
- Never use `implicit` — Scala 2 syntax is banned in Scala 3 style
Enter fullscreen mode Exit fullscreen mode

The implicit → given/using migration is one of the biggest Scala 2 → Scala 3 changes. AI trained on Scala 2 generates implicit val and implicit def everywhere.


Rule 8: Collections — right tool for the operation

Collection selection:
- `List`: prepend-heavy workloads, pattern matching, recursive algorithms
- `Vector`: indexed access, append, large collections (O(log n) operations)
- `Map`/`Set`: lookups and membership tests
- `LazyList`: potentially infinite sequences, streaming data
- `Array`: only when interoperating with Java libraries that require it

Operations:
- `map`, `flatMap`, `filter`, `foldLeft` for transforms
- `groupBy`, `partition`, `span`, `takeWhile` for splitting
- `zipWithIndex` instead of index-based loops
- `traverse` (Cats) for `List[F[A]] => F[List[A]]` (effects in collections)
Enter fullscreen mode Exit fullscreen mode

AI sometimes uses Array for everything (Java habit) or List where Vector is more appropriate. The right collection type matters for performance and idiomatic code.


Rule 9: Futures and concurrency

If using Scala Futures:
- Always provide an explicit `ExecutionContext` — never use `global` in production
- Use `Future.successful`, `Future.failed` for already-complete values
- Chain with `map`, `flatMap`, `recover`, `recoverWith`
- Use `Future.sequence` for parallel execution: `Future.sequence(List(f1, f2, f3))`
- Set timeouts at the boundary — Future itself has no timeout

If using ZIO or Cats Effect (preferred for new code):
- Use `ZIO[R, E, A]` / `IO[E, A]` instead of Future
- Effects are values — compose them without executing until the `main` method
- Use `ZIO.foreachPar` / `IO.parSequence` for parallel effects
- Structured concurrency: `ZIO.scoped` / `Resource` for resource management
Enter fullscreen mode Exit fullscreen mode

Futures with implicit execution contexts are a common AI default. Specify which concurrency model your project uses — ZIO and Cats Effect have very different conventions.


Rule 10: Testing — ScalaTest or MUnit with property-based testing

Testing:
- Framework: ScalaTest (FlatSpec or FunSuite style) or MUnit
- Property-based testing: ScalaCheck for invariant verification
- Style: `"description" should "behavior"` (FlatSpec) or `test("description")` (MUnit/FunSuite)
- Async tests: use `Future`-aware or `IO`-aware test runners
- No `Thread.sleep` in tests — use async assertions or test schedulers
- Fixture setup: `beforeEach`/`afterEach` or `fixture.test`

For ZIO: use `zio-test` with `ZIOSpecDefault`.
For Cats Effect: use `munit-cats-effect`.
Enter fullscreen mode Exit fullscreen mode

Scala testing frameworks are diverse. Without specifying, AI may mix styles or generate blocking tests for async code.


Rule 11: Error types — sealed hierarchies, not strings

Define errors as sealed traits:

  sealed trait AppError
  case class NotFound(id: String) extends AppError
  case class ValidationError(field: String, message: String) extends AppError
  case class DatabaseError(cause: Throwable) extends AppError

Use these as the Left side of Either[AppError, A].
Pattern match on them exhaustively — the compiler will warn if a case is missing.
Never use `String` or `Exception` as an error type in business logic.
Enter fullscreen mode Exit fullscreen mode

String errors are untyped and non-exhaustive. Sealed trait hierarchies give compile-time completeness checking and make error handling explicit and safe.


Rule 12: sbt and dependency hygiene

sbt conventions:
- Pin all dependency versions explicitly in `build.sbt` — no `latest.release`
- Use `dependencyOverrides` for transitive version conflicts
- Organize: `libraryDependencies` grouped by: compile / test / provided
- Enable compiler flags: `-Wunused`, `-Wvalue-discard`, `-Xfatal-warnings` in CI
- Separate modules in a multi-project build for clean dependency boundaries
- Use `scalafmt` for formatting — config in `.scalafmt.conf`, enforced in CI
Enter fullscreen mode Exit fullscreen mode

AI sometimes generates %% version ranges or omits important compiler flags. Strict compiler settings catch bugs before runtime.


Rule 13: Logging with structured context

Logging:
- Use SLF4J + Logback (or ZIO Logging / log4cats for effect systems)
- Structured logging preferred: JSON output in production
- Every log call includes context: `logger.info("Payment processed", Map("userId" -> userId, "amount" -> amount))`
- Log levels: trace/debug (dev), info (normal), warn (degraded), error (failure)
- No `println` or `System.out.println` in production code
- Correlation IDs: propagate via MDC (SLF4J) or ZIO FiberRef / Cats Effect IOLocal
Enter fullscreen mode Exit fullscreen mode

Your CLAUDE.md starting point

# Scala Project — AI Coding Rules

## Version
Scala 3.x. No implicit keyword — use given/using.

## Data
Case classes for domain models. opaque type for type-safe wrappers.
Immutable by default. val over var. copy() over mutation.

## Error Handling
Option for absence. Either[AppError, A] for fallible operations. No null. No exceptions for business logic.
Sealed trait hierarchies for error types — exhaustive pattern matching enforced.

## Style
For-comprehensions over nested flatMap. Pattern matching as primary dispatch.
Immutable collections: List/Vector/Map/Set.

## Effects
[ZIO / Cats Effect / Future — specify]. Effects are values. No Thread.sleep. Structured concurrency.

## Testing
[ScalaTest FunSuite / MUnit / zio-test]. Property-based with ScalaCheck. Async-aware.

## Build
sbt. Pinned dependency versions. scalafmt enforced. -Wunused -Xfatal-warnings in CI.
Enter fullscreen mode Exit fullscreen mode

The gap this closes

The same Scala code compiles whether it's idiomatic or not. The difference between Java-in-Scala and real Scala shows up in maintainability, performance characteristics, and how well the compiler catches bugs.

CLAUDE.md is what makes AI output land on the right side of that line — consistently, across sessions and team members.

The full rules pack across 14+ languages is at gumroad — $27.

Top comments (0)