10 · Scala Development with AI

Scala and AI are a strong pair. The type system and compiler catch a large share of AI mistakes before they run. Scala also has quirks that trip models up. This page shows how to set up, prompt and verify for productive Scala work with AI.

Level 155 minTracks 5, 10, 16Last verified 2026-10

By the end of this page you can

Why Scala + AI works well

Strengths

  • The compiler is a free reviewer. Wrong types, missing cases and bad implicits fail fast.
  • Types are dense context. A signature like IO[Either[Error, Order]] tells the model a lot.
  • ADTs + exhaustive matching force AI-written code to handle every case.
  • Immutability and pure functions make AI changes easier to reason about and test.

Challenges

  • Scala 2 vs Scala 3: models often mix syntaxes (implicit vs given, braces vs indentation).
  • Ecosystem versions: Cats Effect 2 vs 3, Akka vs Apache Pekko, sbt plugin APIs, Spark versions.
  • Less training data than Python or Java, so niche libraries cause more hallucinations.
  • Slow compile cycles make agent loops slower. Use incremental compilation and scoped test commands.

Setup

Toolchain

# macOS (Homebrew):  brew install coursier && coursier setup
# Linux / Windows: see the "Install Scala" page on scala-lang.org for the one-line installer
cs setup                 # installs JDK (if needed), scala, scala-cli, sbt, scalafmt
scala --version
scala-cli --version
ToolRole with AI
scala-cli (the scala runner)Perfect for AI experiments: single-file scripts with //> using directives for Scala version and dependencies. Run AI-generated snippets in seconds.
sbt / MillProject builds. Put the exact commands in AGENTS.md. Prefer scoped commands (module/testOnly) so agent loops are fast.
scalafmtFormats AI output consistently. Good candidate for a post-edit hook.
scalafixLint and automated refactor rules. Catches AI anti-patterns and runs Scala 2→3 migration rules.

IDE options

Metals MCP: let agents ask the compiler

Metals can run an MCP server that exposes Scala-aware tools to any MCP-capable agent. Typical tools cover compiling a file/module/project, inspecting a symbol (members, signatures), getting docs, finding usages, running tests, importing builds, formatting and searching symbols. Instead of guessing what OrderRepo offers, the agent asks Metals.

  1. Use a recent Metals version (VS Code extension, or another Metals client).
  2. Enable the MCP server in settings, e.g. "metals.startMcpServer": true.
  3. Metals writes an MCP config for supported editors, or shows the local URL. Register it with your agent if needed.
  4. For CLI agents without an editor, use the standalone metals-mcp launcher (installable via Coursier) pointed at your workspace.
  5. Add a line to AGENTS.md: "Use the Metals MCP tools to compile and inspect symbols before guessing APIs."
Check current docs Metals MCP is evolving quickly: tool names, settings and the standalone launcher change between releases. Check the Metals documentation and release notes for your version.

Prompting for idiomatic Scala

Always state these four things

  1. Scala version and syntax: "Scala 3.x, significant indentation, given/using". Or "Scala 2.13".
  2. Effect/concurrency model: plain/Future, Cats Effect 3, ZIO 2, or Kyo/Ox/direct style.
  3. Libraries and versions: http4s, Tapir, Circe, Doobie, Skunk, Pekko, Spark 3.x, etc.
  4. Build tool: sbt, Mill, scala-cli, Gradle.

Put these in AGENTS.md once, and they apply to every prompt.

Type-driven development with AI

Types first, then implementation. This plays to Scala's strengths and gives the AI a precise spec.

Scala 3. Design only the types (no implementations yet) for a payment domain:
- A Payment can be Pending, Authorized(authCode), Captured(amount), or Failed(reason).
- Money must not be negative and has a currency.
- IDs must not be confused with each other.
Use enums, opaque types and smart constructors returning Either. Show the code and explain each choice.
//> using scala 3
import java.util.Currency

opaque type PaymentId = String
object PaymentId:
  def apply(s: String): Either[String, PaymentId] =
    Either.cond(s.nonEmpty, s, "PaymentId must not be empty")

final case class Money private (amount: BigDecimal, currency: Currency)
object Money:
  def of(amount: BigDecimal, currency: Currency): Either[String, Money] =
    Either.cond(amount >= 0, Money(amount, currency), s"Negative amount: $amount")

enum FailureReason:
  case Declined, InsufficientFunds, Timeout

enum Payment:
  case Pending(id: PaymentId)
  case Authorized(id: PaymentId, authCode: String)
  case Captured(id: PaymentId, amount: Money)
  case Failed(id: PaymentId, reason: FailureReason)

def describe(p: Payment): String = p match
  case Payment.Pending(id)        => s"$id pending"
  case Payment.Authorized(id, c)  => s"$id authorized ($c)"
  case Payment.Captured(id, m)    => s"$id captured ${m.amount} ${m.currency}"
  case Payment.Failed(id, r)      => s"$id failed: $r"
  // the compiler warns if a case is missing - a great check on AI edits

Follow up with: "Now implement capture(p: Payment, amount: Money): Either[PaymentError, Payment] with all invalid transitions returning errors, and write MUnit tests for every transition."

Translating Python to Scala (and back)

Translate this Python to idiomatic Scala 3 (not a line-by-line port). Use immutable collections,
Option instead of None checks, and pattern matching where clearer. Then list the semantic
differences I should watch for (integer division, mutability, exceptions vs Either).
```python
<paste python>
```

Common AI mistakes in Scala (watch list)

MistakeExampleAsk for instead
Mixed Scala 2/3 syntaximplicit val next to given; import x._ vs import x.*"Scala 3 only, no Scala 2 implicits"
Unsafe Option/collection accessopt.get, list.head, map(key)fold, getOrElse, headOption, get(key)
Exceptions for control flowthrow new IllegalArgumentException in domain codeEither / typed errors
Blocking inside effectsThread.sleep or JDBC calls inside IO without blockingIO.sleep, IO.blocking, ZIO.attemptBlocking
Old library APIsCats Effect 2 ContextShift; Akka imports where you use PekkoState versions, paste docs, compile
Inefficient collectionslist :+ x in a loop; repeated .length on ListVector, builders, foldLeft
Non-exhaustive matchesMissing enum case, case _ => swallowing new casesAvoid wildcard on sealed types
var, null, returnJava-style imperative code"Immutable, expression-oriented, no null"
Invented implicits/instancesCalls a given Codec that doesn't existCompile; use derives clauses explicitly

Spark-specific watch list

Tests as the AI safety net

MUnit

//> using scala 3
//> using test.dep org.scalameta::munit::1.0.0
import java.util.Currency

class PaymentSuite extends munit.FunSuite:
  val eur = Currency.getInstance("EUR")

  test("Money rejects negative amounts"):
    assert(Money.of(-1, eur).isLeft)

  test("Money accepts zero"):
    assertEquals(Money.of(0, eur).map(_.amount), Right(BigDecimal(0)))

  test("describe covers every Payment state"):
    val id = PaymentId("p1").toOption.get // fine in tests
    val all = List(
      Payment.Pending(id),
      Payment.Authorized(id, "A1"),
      Payment.Captured(id, Money.of(10, eur).toOption.get),
      Payment.Failed(id, FailureReason.Timeout)
    )
    all.foreach(p => assert(describe(p).startsWith("p1")))

Property-based tests (ScalaCheck)

Ask the AI for properties instead of examples. Properties catch the edge cases neither of you thought of.

Write ScalaCheck properties (via munit-scalacheck) for my `normalizeEmail(s: String): Option[String]`:
idempotence, output is lowercase, never contains spaces, and Some(x) implies x contains exactly one '@'.
Review AI-written tests extra carefully A test that asserts whatever the code currently does is worthless. Check that each test would fail if the behaviour were wrong. A good trick: ask the AI to introduce a deliberate bug and confirm the tests catch it (a mini mutation test).

Your first LLM call from Scala

No SDK needed. Most providers and local runners accept an OpenAI-compatible chat endpoint. This example uses only the JDK's HTTP client and works against a local Ollama server, so there is no API key and no cost. Level 2 covers proper JSON libraries, streaming and tool calling.

//> using scala 3
//> using jvm 21
import java.net.URI
import java.net.http.{HttpClient, HttpRequest, HttpResponse}

// Local Ollama (https://ollama.com): `ollama pull llama3.2` then `ollama serve`
val endpoint = "http://localhost:11434/v1/chat/completions"
val model    = "llama3.2"

def jsonString(s: String): String =
  "\"" + s.flatMap {
    case '"'  => "\\\""
    case '\\' => "\\\\"
    case '\n' => "\\n"
    case c    => c.toString
  } + "\""

@main def askLlm(question: String): Unit =
  val body =
    s"""{"model": ${jsonString(model)}, "temperature": 0.2,
       | "messages": [
       |   {"role": "system", "content": "You are a concise Scala 3 expert."},
       |   {"role": "user",   "content": ${jsonString(question)}}
       | ]}""".stripMargin

  val request = HttpRequest.newBuilder(URI.create(endpoint))
    .header("Content-Type", "application/json")
    // .header("Authorization", s"Bearer ${sys.env("API_KEY")}")  // for hosted providers
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build()

  val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString())
  println(s"HTTP ${response.statusCode()}")
  println(response.body())  // JSON: look for choices[0].message.content
scala-cli AskLlm.scala -- "Explain opaque types in 3 sentences"
API keys Read keys from environment variables or a secret manager. Never hard-code them, and never commit them. See page 14.

Learning Scala faster with AI

Try it (30 min)
  1. Install scala-cli and run ToyBigram.scala from page 02.
  2. Use the type-driven prompt above for a domain you know (e.g. library loans). Compile the types.
  3. Ask for the implementation and MUnit tests. Run them. Then ask the AI to plant a bug and check the tests catch it.
  4. If you use VS Code, enable Metals MCP and ask the agent to "inspect" one of your types through the tool.

Key takeaways