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.

  • namescala-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

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.

~/.claude/skills/scala-functional/SKILL.md

--- name: scala-functional description: Writes Scala 3 in a functional style with enums and sealed ADTs matched exhaustively, opaque types for domain IDs, given/using type classes… license: MIT ---

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

  1. Read build.sbt (or build.mill, project.scala): Scala version, sbt version in project/build.properties, scalacOptions, and which effect stack is on the classpath (cats-effect, zio, akka/pekko, plain Future).
  2. 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.
  3. 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 -explain temporarily when an error message is unclear; it prints the reasoning.
  • -Wvalue-discard and -Wnonunit-statement catch 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")
NeedUse
Closed set of cases, possibly with dataenum (or sealed trait + case class when cases need their own methods or files)
Distinct ID or unit type with zero runtime costopaque type with a companion constructor
Validated valuefinal case class X private (...) + Either-returning factory
Product datafinal case class (immutable, structural equality)
Optional fieldOption[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 (or import mylib.{given, *}). Library syntax written as Scala 2 implicits (for example cats.syntax.all.*) still arrives with *.
  • Prefer derives for standard instances: case class User(id: Long, name: String) derives CanEqual, or library derivation such as circe’s derives 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]] replaces implicitly; using parameters replace implicit parameter lists. Keep implicit conversions out of new code (they need scala.language.implicitConversions and surprise readers).

Errors as values

LayerRepresentation
Validation, parsing, domain rulesEither[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 codeTry 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

SituationChoose
Small app or script, little concurrencyPlain 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 threadsDirect-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.blocking for blocking calls (JDBC, file I/O, legacy SDKs); IO.interruptible if the call responds to thread interrupts.
  • Resource for anything with a close; never acquire in one IO and release in another.
  • Shared state: Ref[IO, A]; coordination: Deferred, Queue, Semaphore.
  • Bound parallelism (parTraverseN), never unbounded parTraverse over user input.
  • unsafeRunSync() only in main-like edges or legacy bridges; IOApp handles it.
  • Tests: munit with munit-cats-effect (CatsEffectSuite) or weaver; TestControl for 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 ZLayer and provide; the compiler reports missing layers.
  • ZIO.attemptBlocking for blocking code; ZIO.scoped + acquireRelease for resources.
  • Typed errors: keep E a domain enum; use .orDie only for failures that are bugs.
  • Tests: ZIO Test (ZIOSpecDefault, TestClock).

Collections and laziness

  • Default to immutable collections (List, Vector, Map). Vector for indexed access or appends; List for head/tail recursion and prepends.
  • .view or LazyList for large pipelines you short-circuit; fs2 Stream or ZIO ZStream for effectful or unbounded streams.
  • foldLeft over 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.9 and //> using dep org.typelevel::cats-effect:<version>.
  • .scalafmt.conf must set version and runner.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:strictEquality with derives CanEqual to 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.
  • Future starts running on creation; a val f = Future(...) runs once even if you flatMap it twice. IO and ZIO are descriptions and rerun each time.
  • Pattern matching on generic types is erased at runtime (case _: List[Int] matches any list); -unchecked warns.
  • Default ExecutionContext.global is wrong for blocking code; use the effect system’s blocking pool.

Verification

  1. sbt clean compile with -Werror: zero warnings.
  2. sbt test passes; effectful tests use the effect system’s test kit, not Thread.sleep.
  3. sbt scalafmtCheckAll and scalafixAll --check clean.
  4. 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.

esc