Scala 3 shipped as a new compiler, not a new release of the old one. The parser, the typer and the backend were rewritten, the type system was re-grounded on a formal core calculus, and the surface language changed in half a dozen directions that are largely independent of one another. That independence is why "what changed in Scala 3" resists a one-paragraph answer, and why this page is a map rather than a tutorial: each family of changes gets its mechanism and its boundary, then points at the article that develops it.
A redesign, not a version bump
The Scala 2 compiler grew for fifteen years around a type system whose specification was, in practice, its implementation. Scala 3 replaced it with a compiler built against a formal foundation. The visible payoff is that A & B and A | B are ordinary points in the subtyping lattice, reachable by the same rules as any other type, instead of encodings bolted on afterwards. That is a change to what the language is, not to how it is spelled.
For a reader trying to plan work, the useful move is to stop treating Scala 3 as one feature list and sort the changes by risk. They fall into six groups:
- Syntax that is optional and semantically inert - indentation-based blocks, the new control syntax,
endmarkers. - Constructs that replace an existing idiom one-for-one -
given/using,extension,enum, opaque type aliases. - Things removed or narrowed - the group that makes an unmodified Scala 2 file stop compiling.
- Safety modes that are off by default and enabled per project.
- A metaprogramming system that is not source-compatible with the old one at all.
- A distribution format, TASTy, which changes what the word "compatible" means across versions.
Only the third and fifth groups force work on you. Everything else is available when you decide you want it, which is a very different migration shape from the one the phrase "major language revision" suggests. Read the diagram below as layers rather than as a feature list: the surface syntax carries most of the visible change and almost none of the risk, the replacement constructs are local edits, and TASTy underneath is the layer that decides whether you can start at all.
Optional braces, indentation, and the end marker
The same compiler accepts brace syntax and indentation syntax, in the same file, with no mode switch and no semantic difference between them. An indentation region opens after a token that expects a block - =, then, else, do, yield, =>, or a colon placed at the end of a line before a template body - and closes at the first line indented less than the region's opening line.
Indentation width is relative, not fixed. The compiler compares each line against the first line of the enclosing region, so two, three or four spaces all work as long as a file is internally consistent. What it will not do is guess: a mix of tabs and spaces that cannot be ordered unambiguously is a compile error rather than a silent reinterpretation, which is the single most important design decision in the whole feature.
The new control syntax drops the mandatory parentheses, and end markers annotate the close of a long region. Those markers are checked - if end classify does not name the construct actually ending there, that is an error - so they are load-bearing documentation rather than comments that rot.
// Both of these compile to the same typed tree.
def classify(n: Int): String =
if n < 0 then "negative"
else if n == 0 then "zero"
else "positive"
end classify
def classifyBraced(n: Int): String = {
if (n < 0) "negative"
else if (n == 0) "zero"
else "positive"
}What indentation syntax does not change is worth stating plainly, because it is routinely assumed to be riskier than it is. There is no new scoping rule, no change to how given instances are found, and no change to the emitted bytecode. Braces stay legal everywhere, the compiler rewrites mechanically in both directions, and the choice is therefore a style-guide decision rather than a migration item - the only real cost is a formatter configured for one style fighting a file written in the other. The dedicated write-up is Scala 3 indent-based syntax.
What Scala 3 removed or restricted
This is the group that produces compile errors on day one, and it is also the group a rewriting tool handles best, because each removal has a single mechanical replacement.
- Procedure syntax.
def f() { ... }with the=omitted is gone; writedef f(): Unit = .... - Auto-application. Calling a Scala-defined nullary method declared with parentheses as though it had none no longer works. Java-defined methods stay lenient, because the parens carry no intent there.
- Auto-tupling is narrowed. Passing two arguments to a one-parameter method expecting a tuple is no longer silently accepted in most positions - it was a common source of accidental compilation.
- Existential types.
forSomeis gone; wildcard type arguments cover most of what it was used for. See existential types. - Symbol literals and XML literals are dropped from the language.
DelayedInitand early initializers. Theextends { val x = 1 } with Tform is gone; trait parameters, which Scala 3 adds, do the job properly.- Unrestricted implicit conversions. A conversion must now be an instance of a dedicated
Conversiontype, and defining one needs a language import. See Scala implicits for the full account of what the singleimplicitmodifier used to do. do ... whileas a syntactic form is gone; a plainwhilewith the condition restructured replaces it.
Because these are individually small and collectively load-bearing, a migration source level exists that downgrades most of them to warnings carrying an automatic fix, which is what makes the first pass of a migration cheap. The narrower entry is Scala 3 deprecations.
Contextual abstraction: one keyword split into four
The largest ergonomic change is that implicit, which did four unrelated jobs behind one modifier, was decomposed into constructs that say which job they are doing. using declares a parameter the compiler should supply, given declares the instance it supplies, extension adds methods to a type you do not own, and Conversion is the only way to define a coercion. Reading implicit x: Ordering[A] you could not tell whether an instance was being demanded or provided; the new spellings cannot be confused.
// Scala 2
implicit val ordUser: Ordering[User] = Ordering.by(_.name)
def top[A](xs: List[A])(implicit ord: Ordering[A]): A = xs.max
implicit class RichInt(i: Int) { def isEven: Boolean = i % 2 == 0 }
implicitly[Ordering[User]]
// Scala 3
given Ordering[User] = Ordering.by(_.name) // anonymous, found by type
def top[A](xs: List[A])(using Ordering[A]): A = xs.max
extension (i: Int) def isEven: Boolean = i % 2 == 0
summon[Ordering[User]]Three details matter in practice. A given can be anonymous, because it is looked up by type and rarely referenced by name - the compiler synthesises one. A using argument can still be passed explicitly, written top(xs)(using myOrdering), which is the escape hatch Scala 2 lacked a clean spelling for. And extension is a real declaration rather than an implicit class wrapping a value, so the method appears where you would expect it in signatures and errors.
The resolution algorithm underneath is recognisably the same search, with tightened priority and ambiguity rules. The Scala 2 story and the type class pattern are in implicits, the new constructs and derives in Scala 3 contextual abstraction, and the search itself in implicit resolution.
Enums, union and intersection types, opaque aliases
Three type-level additions replace idioms that Scala 2 could express only by encoding. enum collapses the sealed-trait-plus-case-objects boilerplate into one declaration and hands you values, valueOf and ordinal without writing them; the parameterised form is a genuine algebraic data type, with the parent type argument inferred per case.
enum Color:
case Red, Green, Blue // Color.values, Color.valueOf("Red")
enum Tree[+A]: // a real ADT, exhaustively matchable
case Leaf(value: A)
case Node(left: Tree[A], right: Tree[A])
def parse(s: String): Int | String = ... // union: no Either wrapper, no allocation
type Resource = Readable & Closeable // intersection: both, commutatively
opaque type UserId = Long // erases to Long, distinct at compile time
object UserId:
def apply(l: Long): UserId = lUnion and intersection types are first-class members of the subtyping lattice rather than sugar. A | B is commutative and unwrapped - there is no runtime tag, so it costs nothing, and the price of that is that a union is erased to a common supertype on the JVM and cannot be discriminated by type test alone in the general case. Inference also widens a union to its join unless the expected type is itself a union, so union-returning methods usually want an explicit return type. Intersection A & B replaces the Scala 2 compound type A with B and, unlike it, is genuinely commutative.
Opaque type aliases give a wrapper that exists only for the typer and erases to the underlying representation, with none of the boxing caveats of value classes. Depth lives in Scala 3 enums, the type system article, opaque types, match types and case classes.
Top-level definitions and export clauses
Scala 2 required every def and val to live inside a class, object or trait, so a package-level helper meant inventing a wrapper object whose only purpose was to hold it. Scala 3 allows definitions directly in a package. They are compiled into a synthetic per-file object, so the bytecode shape and the Java-caller view are unchanged - the change is to the source, not the model. Givens and extension methods benefit most, because a package object was the conventional home for them and package objects carried their own inheritance restrictions.
export is the other addition that rarely makes the highlight reels and changes day-to-day design more than most that do. It generates forwarders to selected members of a value you already hold, which is composition-over-inheritance made syntactic: instead of extending a class to inherit its API, hold an instance and re-expose exactly the parts you meant to.
package myapp // no wrapper object needed
def render(x: Int): String = x.toString
class Service(repo: Repo, cache: Cache):
export repo.{find, save} // Service.find delegates to repo.find
export cache.* // re-expose the whole cache APIThe forwarders are real methods on Service, so exported names appear in its signature and take part in overload resolution. Unlike inheritance, you choose which members cross the boundary and you do not acquire the implementation type as a supertype, which means an exported dependency can be swapped without breaking every type test written against it. The stub entry is Scala 3 top-level definitions.
The safety switches, and why they are opt-in
Several of the most interesting additions are off unless you enable them, which is easy to miss when reading a feature list that presents them alongside enum and given. Each is off for the same reason: turning it on unconditionally would break a large fraction of existing code.
Matchable
In Scala 2, pattern matching and type tests are legal on any value, including one whose static type is an abstract parameter or Any. That interacts badly with any abstraction meant to hide its representation - an opaque type can be matched back to its underlying form, defeating the point. Scala 3 introduces Matchable as a trait sitting between Any and the concrete universes, and matching on something not known to be Matchable is reportable under the relevant compiler setting. It is soft by default precisely because it would otherwise be a wall.
Strict equality
== is universal in Scala: Some(1) == 1 type-checks and is permanently false, and comparing a String to a UUID compiles. Scala 3 can require evidence that two types are comparable before allowing == between them, turning that entire bug class into a compile error. It is a mode you switch on, and switching it on means providing or deriving that evidence for your own types - a real cost, and the reason it is a per-project decision rather than a default.
Explicit nulls, open and infix
Explicit nulls is another opt-in mode: reference types become non-nullable, null inhabits only a union with the null type, and Java-derived types are seen through a nullable projection, so the checks land at the boundary where the risk actually is - which is where Option was always meant to be applied by hand. Separately, open marks a class as intended for extension and infix marks a method as intended for operator-position calls; both write down an intent Scala 2 had no vocabulary for, and both are enforced only under the corresponding setting.
Metaprogramming: inline, quotes and splices
The Scala 2 macro system was never a stable language feature - it was an experimental hook into compiler internals, written against scala-reflect, whose API was the compiler's own data structures. Scala 3 replaced it outright with a layered design, and the layering is the point: reach for the least powerful level that works.
inline is the bottom layer and handles most needs on its own. It is a guarantee, not an optimiser hint: an inline def is expanded at every call site, inline parameters are required to be constants or expressions the compiler can substitute, and inline match plus inline if let a definition branch at compile time. transparent inline goes further and lets the expanded body's more precise type escape, which is how a call can return a narrower type than the declared signature.
Above that sits Mirror-based derivation - case class User(...) derives Codec, with a derived given in the companion assembling an instance from the compiler-synthesised structural description. Full macros are the top layer: a quote '{ ... } builds an Expr[T], a splice ${ ... } runs code at compile time and drops its result into the tree.
import scala.quoted.*
inline def requirePositive(inline n: Int): Unit = ${ impl('n) }
def impl(n: Expr[Int])(using Quotes): Expr[Unit] =
'{ if $n <= 0 then throw new IllegalArgumentException("must be positive") }Quotes and splices are typed, so a macro that builds an ill-typed tree usually fails to compile itself rather than producing broken code downstream. The full treatment is Scala 3 metaprogramming, with quotes and splices and transparent inline as the narrower entries, and the API the old macros used in Scala reflection.
TASTy, and what actually crosses the version boundary
Every Scala 3 compilation emits, alongside the class files, a TASTy file: the fully typed abstract syntax tree in a binary format. It is not a signature table. It holds the tree the typer produced, so it retains what the JVM's erased signatures discard - type arguments, union and intersection types, given parameters, inline bodies.
That format is why "compatible" means something different here. Scala 2 minor versions were mutually binary incompatible because each encoded the type language into bytecode slightly differently, so every library cross-published across a matrix and carried its Scala version in the artifact name - what that cost the ecosystem is in Scala overview.
Two directions work. Scala 3 reads Scala 2.13 class files and their pickled signatures directly, so a 2.13 dependency is usable from a Scala 3 build once the build tool is told to keep the _2.13 artifact suffix rather than rewriting it to _3 - in sbt, CrossVersion.for3Use2_13. In the other direction, 2.13 consumes Scala 3 artifacts through a TASTy reader that walks the typed tree and reconstructs what 2.13 can express.
What does not cross is macros, and that single exception is most of the difficulty. A Scala 2 macro is not data sitting in an artifact - it is compiled code that ran inside the Scala 2 compiler against a reflection API that does not exist in Scala 3. It cannot be read, translated or shimmed; it has to be rewritten. That is why the long pole in the ecosystem migration was the derivation, serialization and testing libraries rather than application code.
Going forward TASTy is also the stability unit: 3.x releases commit to reading TASTy produced by earlier 3.x, which is what collapses the cross-publishing matrix to a single column, and the 3.3 LTS line is where projects that want a slow-moving target sit. See Scala 3 stability and roadmap.
The migration picture, honestly
The mechanical part is genuinely mechanical, and it starts before you change compilers. Building the existing 2.13 code with -Xsource:3 turns on a subset of Scala 3 semantics and warnings inside the Scala 2 compiler, so ambiguity that would become an error later surfaces while you are still on a toolchain that everything supports. Then the Scala 3 compiler under -source:3.0-migration downgrades the removed constructs to warnings that carry an automatic fix, and -rewrite applies them in place; Scalafix rules cover what the compiler does not encode. For a codebase that is plain Scala 2.13 with ordinary implicits and no macros, this is a day of work, not a quarter.
The non-mechanical part is a short list, and it is the same list every time:
- Macros. Rewritten, not ported. If you wrote one, budget for it; if you merely depend on one, you are waiting on somebody else's schedule.
- Runtime reflection. Code built on the Scala 2 reflection library, particularly anything using type tags, needs a different approach - see Scala reflection.
- Generic derivation on Shapeless 2, which was replaced rather than upgraded. Shapeless and Shapeless 3 cover the gap.
- Compiler plugins, which target a compiler that no longer exists. See compiler plugins.
- Implicit resolution corner cases, where Scala 2's rules and Scala 3's given search do not agree and the code silently resolves to a different instance rather than failing.
The build is usually the real schedule. Your own code compiles; the blocker is the dependency furthest down the tree that has not published a Scala 3 artifact. Because Scala 3 consumes 2.13 artifacts, you can often unblock by pinning that one dependency to its 2.13 build - unless it is a macro library, in which case the escape hatch is exactly the one that does not exist. A library rather than an application usually cross-builds instead, publishing for both versions off one source tree plus a little version-specific source, and lives with the shared code being the intersection of both dialects. Sequencing around the dependency graph rather than your own module graph is what separates a short migration from a stalled one. The step-by-step version is in the migration guide, and the build-side mechanics in sbt and Mill.
Scala 3 is a new compiler on a new foundation wearing a familiar surface. Most of what changed is opt-in: indentation syntax, strict equality, explicit nulls, Matchable and open are all things you switch on when you want them, and given/using, extension, enum and opaque types are one-for-one replacements you adopt file by file. Two things are not optional - the removed Scala 2 constructs, which -Xsource:3 and the migration source level handle well, and the macro system, which they cannot handle at all. TASTy is what lets a Scala 3 build consume the 2.13 ecosystem while it catches up, so plan the migration around your macro-using dependencies and treat everything else as a style decision you make once.