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.
By the end of this page you can
- Set up IntelliJ IDEA or VS Code + Metals for AI-assisted Scala, including the Metals MCP server.
- Write prompts that produce idiomatic Scala 3 for your stack.
- Recognise the most common AI mistakes in Scala and Spark code.
- Use type-driven development, MUnit and ScalaCheck as an AI safety net.
- Call an LLM API from plain Scala with no extra libraries.
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 (
implicitvsgiven, 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
| Tool | Role 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 / Mill | Project builds. Put the exact commands in AGENTS.md. Prefer scoped commands (module/testOnly) so agent loops are fast. |
| scalafmt | Formats AI output consistently. Good candidate for a post-edit hook. |
| scalafix | Lint and automated refactor rules. Catches AI anti-patterns and runs Scala 2→3 migration rules. |
IDE options
- IntelliJ IDEA + Scala plugin: deep Scala support, refactorings, inspections. AI through JetBrains AI Assistant/Junie or plugins like Copilot. See page 06.
- VS Code + Metals: lightweight, with the biggest selection of AI extensions and agent harnesses.
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.
- Use a recent Metals version (VS Code extension, or another Metals client).
- Enable the MCP server in settings, e.g.
"metals.startMcpServer": true. - Metals writes an MCP config for supported editors, or shows the local URL. Register it with your agent if needed.
- For CLI agents without an editor, use the standalone
metals-mcplauncher (installable via Coursier) pointed at your workspace. - Add a line to
AGENTS.md: "Use the Metals MCP tools to compile and inspect symbols before guessing APIs."
Prompting for idiomatic Scala
Always state these four things
- Scala version and syntax: "Scala 3.x, significant indentation,
given/using". Or "Scala 2.13". - Effect/concurrency model: plain/
Future, Cats Effect 3, ZIO 2, or Kyo/Ox/direct style. - Libraries and versions: http4s, Tapir, Circe, Doobie, Skunk, Pekko, Spark 3.x, etc.
- 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)
| Mistake | Example | Ask for instead |
|---|---|---|
| Mixed Scala 2/3 syntax | implicit val next to given; import x._ vs import x.* | "Scala 3 only, no Scala 2 implicits" |
| Unsafe Option/collection access | opt.get, list.head, map(key) | fold, getOrElse, headOption, get(key) |
| Exceptions for control flow | throw new IllegalArgumentException in domain code | Either / typed errors |
| Blocking inside effects | Thread.sleep or JDBC calls inside IO without blocking | IO.sleep, IO.blocking, ZIO.attemptBlocking |
| Old library APIs | Cats Effect 2 ContextShift; Akka imports where you use Pekko | State versions, paste docs, compile |
| Inefficient collections | list :+ x in a loop; repeated .length on List | Vector, builders, foldLeft |
| Non-exhaustive matches | Missing enum case, case _ => swallowing new cases | Avoid wildcard on sealed types |
var, null, return | Java-style imperative code | "Immutable, expression-oriented, no null" |
| Invented implicits/instances | Calls a given Codec that doesn't exist | Compile; use derives clauses explicitly |
Spark-specific watch list
.collect()on large DataFrames: it pulls everything to the driver. Ask for aggregations or.limit.- UDFs where built-in functions exist: they are slower and opaque to the optimiser. Ask "use
org.apache.spark.sql.functionsonly". - Wrong Spark/Scala version combination: Spark 3.x builds target Scala 2.12/2.13, while Spark 4 targets Scala 2.13. State your cluster's versions.
- Join explosions and skew: ask the AI to explain the physical plan (
df.explain("formatted")). It is very good at reading plans. - Non-deterministic logic in transformations, and actions inside loops.
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 '@'.
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"
Learning Scala faster with AI
- Explain the compiler error: paste the full error. Scala 3 errors are good, but AI can explain type-inference failures in plain words.
- "Show me three ways": recursion vs
foldLeftvs collection combinators. Then ask which is most idiomatic and why. - Desugar it: "Show what this for-comprehension desugars to" (
flatMap/map/withFilter). - Type-class tutor: "Explain
Functor,Applicative,Monadusing Option, List and IO, with tiny examples I can run in scala-cli." - Code kata partner: "Give me an exercise on pattern matching, wait for my solution, then review it."
- Install scala-cli and run
ToyBigram.scalafrom page 02. - Use the type-driven prompt above for a domain you know (e.g. library loans). Compile the types.
- Ask for the implementation and MUnit tests. Run them. Then ask the AI to plant a bug and check the tests catch it.
- If you use VS Code, enable Metals MCP and ask the agent to "inspect" one of your types through the tool.
Key takeaways
- Scala's types and compiler make AI output safer. Keep the compiler in the loop.
- Always specify Scala version, effect system, libraries and build tool, ideally in
AGENTS.md. - Watch for Scala 2/3 mix-ups, unsafe
.get, exceptions, blocking in IO, old APIs and Spark anti-patterns. - Use types first, then implementation, then tests (MUnit + ScalaCheck).
- Metals MCP lets agents query the real compiler instead of guessing.