Scala 3 Functional skill for Claude Code
Exhaustive ADTs, typed errors and one effect system. Claude models domains with Scala 3 enums, opaque types and validated constructors so bad states fail to compile, keeps errors as values until the program edge, and picks Cats Effect or ZIO to match the codebase instead of mixing them. It turns on strict scalacOptions, places givens where they resolve without imports, and respects the 3.3 and 3.9 LTS split for libraries.
- name
scala-functional - size242 lines · ≈2,500 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes Scala 3 in a functional style with enums and sealed ADTs matched exhaustively, opaque types for domain IDs, given/using type classes with extension methods and derives, Either-based error handling, and effect systems chosen deliberately (Cats Effect with fs2 and http4s, or ZIO with layers), built with sbt or Scala CLI under strict compiler warnings. Use when writing or reviewing Scala 3, migrating from Scala 2 implicits, choosing between Future, Cats Effect and ZIO, configuring build.sbt or scalacOptions, or fixing given resolution and type inference errors. Try asking
- Convert these Scala 2 implicit classes and implicit vals to Scala 3 givens and extensions
- Model our order lifecycle as a Scala 3 enum and make every match exhaustive
- This Cats Effect service blocks the compute pool — find the blocking calls and fix them
Use it when
- Writing or reviewing Scala 3 code or build.sbt
- Migrating from Scala 2.13 implicits and macros
- Choosing between Future, Cats Effect, ZIO or direct style
- Given resolution, ambiguity or type inference errors
Skip it when
- Pure JVM Java code without Scala — use java-modern
- Kotlin coroutines and Flow — use kotlin-idioms
- Learning type classes in a lazy pure language — use haskell-functional
How to check the result
sbt clean compile passes with -Werror and the strict warning flags, sbt test is green using the effect system's test kit, and scalafmt and scalafix checks are clean.
The common mistake it prevents
Writing IO or ZIO values as statements and discarding them, so the effect silently never runs; -Wvalue-discard and -Wnonunit-statement catch it.
Done well means: illegal states are unrepresentable (enums, opaque types, smart
constructors), every match is exhaustive with warnings as errors, failures are values
(Either, typed effect errors) until the program edge, side effects live inside one
effect system, and sbt test passes with scalafmt and scalafix clean.
Before changing anything
- Read
build.sbt(orbuild.mill,project.scala): Scala version, sbt version inproject/build.properties,scalacOptions, and which effect stack is on the classpath (cats-effect,zio,akka/pekko, plainFuture). - Version policy: Scala 3.9 is the current LTS (released September 2026, needs JDK 17+); 3.3 is the previous LTS. Libraries publish on the LTS so LTS users can depend on them; applications can track the latest Scala Next release. Do not bump a library’s Scala version casually: artifacts built with 3.9 cannot be consumed by 3.3 projects.
- Never mix effect systems in one service. Follow whichever the codebase already uses.
Compiler flags
ThisBuild / scalaVersion := "3.9.0" // check for the latest 3.9.x patch
ThisBuild / scalacOptions ++= Seq(
"-deprecation", "-feature", "-unchecked",
"-Wunused:all", "-Wvalue-discard", "-Wnonunit-statement",
"-Werror", // CI; locally you may drop it while iterating
)
- Add
-explaintemporarily when an error message is unclear; it prints the reasoning. -Wvalue-discardand-Wnonunit-statementcatch effects built and thrown away (IO.println("x")as a statement does nothing).
Modeling the domain
enum PaymentState:
case Pending
case Authorized(authId: String)
case Failed(reason: String, retryable: Boolean)
object ids:
opaque type UserId = Long
object UserId:
def parse(raw: String): Either[String, UserId] =
raw.toLongOption.filter(_ > 0).toRight(s"invalid user id: $raw")
extension (id: UserId) def value: Long = id
final case class Email private (value: String)
object Email:
def from(raw: String): Either[String, Email] =
if raw.contains('@') then Right(Email(raw.trim.toLowerCase)) else Left(s"bad email: $raw")
| Need | Use |
|---|---|
| Closed set of cases, possibly with data | enum (or sealed trait + case class when cases need their own methods or files) |
| Distinct ID or unit type with zero runtime cost | opaque type with a companion constructor |
| Validated value | final case class X private (...) + Either-returning factory |
| Product data | final case class (immutable, structural equality) |
| Optional field | Option[A], never null |
Match exhaustively; never add a wildcard case _ to a match on your own ADT, or the
compiler cannot tell you when a new case is added.
Type classes with given/using
trait Show[A]:
extension (a: A) def show: String
final case class Money(cents: Long, currency: String)
object Money:
given Show[Money] = new Show[Money] {
extension (m: Money) def show = f"${m.cents / 100.0}%.2f ${m.currency}"
}
def render[A: Show](items: List[A]): String = items.map(_.show).mkString(", ")
- Put instances in the companion of the data type or of the type class; they are then found without imports (implicit scope).
- Wildcard imports do not bring givens:
import mylib.given(orimport mylib.{given, *}). Library syntax written as Scala 2 implicits (for examplecats.syntax.all.*) still arrives with*. - Prefer
derivesfor standard instances:case class User(id: Long, name: String) derives CanEqual, or library derivation such as circe’sderives Codec.AsObject. - Scala 3.6+ supports newer given syntax (
given Show[Money]:blocks,[A: Show as s]context-bound names). Use it only if every build target is 3.6 or later. summon[Show[A]]replacesimplicitly;usingparameters replace implicit parameter lists. Keep implicit conversions out of new code (they needscala.language.implicitConversionsand surprise readers).
Errors as values
| Layer | Representation |
|---|---|
| Validation, parsing, domain rules | Either[DomainError, A] where DomainError is an enum |
| Effectful code (Cats Effect) | IO[A] with domain errors as IO[Either[E, A]] or EitherT, infrastructure failures as raised Throwable |
| Effectful code (ZIO) | ZIO[R, E, A] with E a domain enum; defects (bugs) stay unchecked |
| Interop with Java/legacy code | Try or Either.catchNonFatal-style wrappers at the boundary only |
| Program edge (HTTP handler, CLI main) | map errors to responses/exit codes once |
def register(rawId: String, rawEmail: String): Either[String, (UserId, Email)] =
for
id <- UserId.parse(rawId)
email <- Email.from(rawEmail)
yield (id, email)
Never throw for expected outcomes, and never call .get on Option/Either/Try
outside tests.
Choosing the effect approach
| Situation | Choose |
|---|---|
| Small app or script, little concurrency | Plain functions + Either; Future only for existing async APIs |
| Typelevel stack (http4s, doobie, fs2, skunk) | Cats Effect 3 |
| ZIO ecosystem (zio-http, zio-json, ZLayer DI) | ZIO 2 |
| Team prefers direct style on JDK 21 virtual threads | Direct-style libraries (for example Ox); keep it consistent |
Cats Effect essentials
import cats.effect.{IO, IOApp, Resource}
import scala.concurrent.duration.*
object Main extends IOApp.Simple:
def client: Resource[IO, HttpClient] =
Resource.make(IO.blocking(HttpClient.open()))(c => IO.blocking(c.close()))
def fetch(c: HttpClient, id: Long): IO[Result] =
IO.blocking(c.get(id)).timeout(5.seconds)
val run: IO[Unit] =
client.use { c =>
IO.parTraverseN(8)(List.range(1L, 100L))(id => fetch(c, id))
.flatMap(rs => IO.println(s"fetched ${rs.size}"))
}
IO.blockingfor blocking calls (JDBC, file I/O, legacy SDKs);IO.interruptibleif the call responds to thread interrupts.Resourcefor anything with a close; never acquire in oneIOand release in another.- Shared state:
Ref[IO, A]; coordination:Deferred,Queue,Semaphore. - Bound parallelism (
parTraverseN), never unboundedparTraverseover user input. unsafeRunSync()only inmain-like edges or legacy bridges;IOApphandles it.- Tests: munit with munit-cats-effect (
CatsEffectSuite) or weaver;TestControlfor time-dependent code.
ZIO essentials
import zio.*
final case class Config(url: String)
object Config:
val live: ULayer[Config] = ZLayer.succeed(Config("http://localhost:8080"))
object Main extends ZIOAppDefault:
val program: ZIO[Config, Throwable, Unit] =
for
cfg <- ZIO.service[Config]
_ <- Console.printLine(s"connecting to ${cfg.url}")
yield ()
def run = program.provide(Config.live)
- Dependencies through
ZLayerandprovide; the compiler reports missing layers. ZIO.attemptBlockingfor blocking code;ZIO.scoped+acquireReleasefor resources.- Typed errors: keep
Ea domain enum; use.orDieonly for failures that are bugs. - Tests: ZIO Test (
ZIOSpecDefault,TestClock).
Collections and laziness
- Default to immutable collections (
List,Vector,Map).Vectorfor indexed access or appends;Listfor head/tail recursion and prepends. .vieworLazyListfor large pipelines you short-circuit; fs2Streamor ZIOZStreamfor effectful or unbounded streams.foldLeftover recursion unless the recursion is@tailrec-annotated.
Build tooling
sbt ~compile # watch mode
sbt "testOnly *UserSpec" # one suite
sbt scalafmtCheckAll scalafixAll --check
sbt dependencyUpdates # with sbt-updates plugin
scala run Main.scala # Scala CLI (the `scala` command) for scripts and spikes
- sbt 2.x is out (Scala 3 build definitions, JDK 17, remote cache). Migrating from 1.x is a separate task: check every plugin has a 2.x release first.
- Scala CLI scripts declare dependencies inline:
//> using scala 3.9and//> using dep org.typelevel::cats-effect:<version>. .scalafmt.confmust setversionandrunner.dialect = scala3.%%appends the Scala binary suffix (_3); use%only for Java libraries.
Migrating from Scala 2
- [ ] 1. Cross-build on 2.13 with -Xsource:3 and fix warnings
- [ ] 2. Switch scalaVersion to 3.x; compile with -source:3.0-migration -rewrite
- [ ] 3. Replace macros (Scala 2 macros do not run on Scala 3) with inline/derivation or library upgrades
- [ ] 4. Convert implicit classes to extension methods, implicit vals to givens
- [ ] 5. Replace sealed trait + case objects with enums where it reads better
- [ ] 6. Turn on the strict scalacOptions above and fix everything
Scala 3 can depend on most Scala 2.13 artifacts (.cross(CrossVersion.for3Use2_13)),
except those exposing macros.
Gotchas
==compiles between unrelated types; enable-language:strictEqualitywithderives CanEqualto make it a compile error.- Given ambiguity: two instances in scope at equal priority produce “ambiguous given”. Put the default in the companion and the override in local scope, or name and import one explicitly.
Futurestarts running on creation; aval f = Future(...)runs once even if youflatMapit twice.IOandZIOare descriptions and rerun each time.- Pattern matching on generic types is erased at runtime (
case _: List[Int]matches any list);-uncheckedwarns. - Default
ExecutionContext.globalis wrong for blocking code; use the effect system’s blocking pool.
Verification
sbt clean compilewith-Werror: zero warnings.sbt testpasses; effectful tests use the effect system’s test kit, notThread.sleep.sbt scalafmtCheckAllandscalafixAll --checkclean.- For new ADT cases, confirm the compiler flagged every match that needed updating (no wildcard swallowed it).
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.