One language, two traditions, one JVM

Scala is a statically typed language that compiles to JVM bytecode and deliberately refuses to choose between object orientation and functional programming. Every value is an object, including functions, which are objects with an apply method; and every construct is an expression that produces a value, including if, match, and a block of statements. There is no statement/expression split to work around and no primitive/object split at the source level - Int sits under AnyVal, String under AnyRef, and both under Any, with Nothing as a bottom type that is a subtype of everything.

Those two decisions are not decoration. They are what lets a Scala library express things a Java library cannot: a module can be a value, an abstract data type can carry methods, a control structure can be a user-defined method taking a by-name parameter. The cost is a language with a large surface area and a compiler that does real work to make it fit on a platform designed for something else.

This article is the map. Each section takes one layer at overview altitude and then points at the article that goes deep on it.

Scala language pillarsStatic typeswith inferenceFunctionalimmutable, HOFOOeverything is objectCompiles to JVM bytecode; interops with Java in both directions
Three foundations of Scala.
Advertisement

What the object-functional fusion actually buys

The fusion shows up in three concrete places. First, traits: interfaces that can carry concrete members and state, linearized at mixin time so that a class composing several traits gets one deterministic method resolution order rather than a diamond problem. On JVM 8 and later a trait with concrete methods compiles to an interface with default methods, so the abstraction is cheap. See traits and mixin composition, with self types for the dependency-declaring variant.

Second, functions as values. A lambda is an ordinary object of type Function1[A, B], so it can be stored in a field, put in a collection, or returned from a method, and higher-order methods are just methods. That is why the collections API is a fluent chain rather than a separate stream abstraction bolted on. See functions as values and higher-order functions.

Third, and most consequential, objects as modules. Because a value can carry types as members and a type can depend on a value's path, a Scala object or instance is a first-class module. This is what makes the type class encoding work: a type class instance is just a value that the compiler finds for you. The whole mechanism is type classes, with the resolution machinery in the implicits article.

The type system is the centre of gravity

More than any other JVM language, Scala moves design decisions into types. Three features do most of the work.

Algebraic data types come from sealed trait plus case class. Sealing restricts the subtypes to one compilation unit, which lets the compiler prove a match is exhaustive; case supplies structural equality, hashCode, toString, copy, and an extractor. Deep dives: sealed traits and ADTs, case classes, and in Scala 3 enums, which give the same shape in one declaration.

Variance is declaration-site, not use-site. You write class Box[+A] once and every user gets the subtyping relation, instead of Java's ? extends wildcards repeated at every signature. The compiler checks the annotation against every position the parameter appears in, which is why a covariant container's add method has to widen its parameter with a lower bound. Higher-kinded types go one step further and abstract over type constructors, so a signature can be generic in F[_] rather than in a concrete container - that is the door to Functor, Monad, and every tagless-final API. Both live in the type system article, with higher-kinded types and path-dependent types alongside it.

Type inference, and where it gives up

Scala uses local type inference, not the global Hindley-Milner inference of ML or Haskell. It infers from the inside out and left to right within an expression, which means it works beautifully in the common case and has a set of well-known cliffs. Method parameters are never inferred. A recursive method must have an explicit result type, because there is nothing to infer from yet. Public API members should carry explicit types anyway, since an inferred type is part of your binary contract and can widen when you edit the body.

The failure modes are worth recognising on sight. Unifying two unrelated branch types infers their least upper bound, which is often Any or a structural refinement, and the error then surfaces hundreds of lines away where the value is used. An empty collection or a throwing branch can infer Nothing, which type-checks everywhere and fails at runtime. And inference interacts with implicit search: the compiler resolves overloads and searches for givens using types it is simultaneously inferring, which is a large part of why error messages get long. Scala 3's union types cut down the Any-widening case specifically. Details in type inference.

Pattern matching is a control structure, not sugar

match is an expression that returns a value, and it destructures rather than merely tests. A pattern can bind by structure (case Order(id, Item(sku, qty) :: rest)), by type (case s: String), by constant, by alternation, or by guard. Case classes get an unapply for free, and any object with a suitably shaped unapply or unapplySeq becomes a pattern - extractors are user-extensible, so parsing and validation logic can present itself as a pattern.

The property that changes how you design is exhaustivity checking. Match on a sealed hierarchy and the compiler warns about the case you forgot; add a variant a year later and every match site that needs updating tells you at compile time. This only holds if the hierarchy is sealed and you have warnings escalated to errors, which is why -Xfatal-warnings is the setting that makes ADTs pay off rather than a matter of taste.

Two mechanical caveats. Erasure means type patterns over generics cannot be checked at runtime - case xs: List[Int] matches any List and the compiler warns "unchecked". And a match on many cases is generally a chain of type tests and equality checks, not a jump table; only matches over constants compile to a tableswitch, which the @switch annotation will verify for you. Control flow generally: control flow; type-level matching in Scala 3: match types.

Advertisement

Immutability by default and the collections design

val versus var is the smallest version of the language's central bet: rebinding is opt-in, and the default import puts the immutable collections in scope so that Map and Set without qualification are persistent structures. Persistent means an update returns a new value that shares most of its structure with the old one, so list.tail is free and vector.updated(i, x) copies a handful of small arrays rather than the whole collection. See val vs var and immutable vs mutable collections.

The library's design goal is that every operation is available on every collection and returns the most specific sensible type - map on a Map gives a Map when the function returns pairs, and a List when it does not. Scala 2.12 achieved that with the notorious CanBuildFrom, whose implicit signatures leaked into every error message; 2.13 rebuilt the hierarchy around IterableOps and a much smaller BuildFrom, which is the single most user-visible library change of the Scala 2 line.

The performance consequences deserve their own treatment: List is a cons cell chain with O(1) prepend and no random access, Vector is a wide trie with effectively constant-time indexing, and generic collections box primitives because JVM generics erase. Structure choice, allocation, and boxing are covered in collections performance; the tour of the types themselves is the collections article, with List, Vector, and the lazy variants views and LazyList.

Contextual abstraction, at a glance

One paragraph, because this is the largest subject in the category and it has its own articles. Scala lets you declare that a parameter should be supplied by the compiler from the types at the call site rather than written by the caller. In Scala 2 that is implicit; in Scala 3 it is split into given/using for the common case and separate keywords for the rarer ones, precisely because one keyword doing four jobs was the language's biggest usability wound. The mechanism carries type class instances, execution contexts, extension methods, and evidence of type-level facts, and the search that finds them is the main reason Scala compilation is slow and Scala error messages are long.

Full treatment: implicits, type classes, and conversions; the Scala 3 form in given/using; the search algorithm and its ambiguity and priority rules in implicit resolution; the architectural pattern built on top in tagless final; and mechanical derivation in Shapeless.

Scala 2 to Scala 3 - what changed at the language level

Scala 3 is a new compiler on a new theoretical foundation, not a version bump. The type system was re-grounded on the DOT calculus, which is why intersection types (A & B) and union types (A | B) exist as first-class constructs rather than encodings. Contextual abstraction was decomposed as described above. enum collapses the sealed-trait-plus-case-objects boilerplate. Opaque types give a zero-cost newtype that erases to the underlying representation without the allocation caveats of value classes. Metaprogramming was rebuilt from scratch: the unprincipled Scala 2 macro system was replaced by inline, quotes and splices, and Mirror-based derivation - see Scala 3 metaprogramming.

Syntax changed too. Indentation-based blocks and the new control syntax are optional but idiomatic, and top-level definitions remove the need for wrapper objects. Several Scala 2 behaviours were removed or restricted, including automatic ()-insertion, some auto-tupling, and unrestricted implicit conversions.

The migration story is unusually good for a breaking release: Scala 3 reads Scala 2.13 artifacts directly, 2.13 can read Scala 3 artifacts through the TASTy reader, and -source:3.0-migration plus Scalafix rules automate most of the mechanical work. The friction is concentrated in macro-heavy libraries, which have to be rewritten rather than ported. See Scala 3 overview, the migration guide, deprecations, and stability and roadmap.

Java interop and the friction points

Interop is bidirectional and mostly free in one direction: Scala calls Java classes as if they were Scala classes, and a Java library appears in Scala with its ordinary signatures. Calling Scala from Java is where the abstractions leak, and the leaks are predictable.

Names are mangled. Operators become encoded identifiers ($plus), and a Scala object is compiled to a class with a MODULE$ singleton field plus static forwarder methods on a companion class, so Foo.bar() from Java only works when the forwarders were generated - which they are not when a companion class defines a conflicting member. Default parameters compile to synthetic $default$1 methods that Java callers must invoke by hand. Traits with concrete members map to interfaces with default methods, but trait fields and initialization order do not.

The type mismatches matter more. scala.collection.immutable.List is not java.util.List; conversion is explicit through scala.jdk.CollectionConverters (renamed from JavaConverters in 2.13), and every conversion is a wrapper with its own cost and mutability semantics. Scala's boxed primitives, its Unit, and its variadic encoding all need care. Above all, Java's null flows into Scala unchecked - Option is a convention Scala code follows, not a guarantee about anything crossing the boundary, so wrapping at the edge is the discipline that keeps the invariant true. See Option, Either, Try for the boundary types, companion objects for the static-forwarder mechanics, and value classes for where the erasure escape hatch does and does not hold.

Compilation, tooling, and the binary compatibility problem

Scalac does far more than javac: over twenty phases including full type inference, implicit search, macro expansion, pattern-match analysis, and erasure of a much richer type language onto the JVM's. Compilation is commonly an order of magnitude slower than javac on equivalent source volume, and the two dominant costs are implicit search and inlined or macro-generated code. That fact shapes daily work - which is why the Zinc incremental compiler underneath sbt and Mill matters so much, and why Metals talks to the build over BSP and keeps a presentation compiler warm instead of invoking the batch compiler.

The deeper structural fact is binary compatibility. Scala 2 minor versions are mutually binary incompatible, because the encoding of the type system into bytecode changes between them. Every published library therefore carries its Scala version in the artifact name - cats-core_2.13, cats-core_3 - and the whole ecosystem cross-publishes across a matrix rather than shipping one jar. One heavyweight dependency pinned to an old version pins everything downstream with it, which is exactly what happened while Spark stayed on 2.12 and kept a large part of the data ecosystem there. Scala 3 changes the deal by making TASTy the compatibility unit and promising binary stability across 3.x minors, so the matrix collapses to one column going forward. Dependency resolution and the coursier cache are covered separately; compiler extension points are in compiler plugins and reflection.

Where Scala earns its complexity

Scala is a bad default and a very good specific choice. It earns its cost where a type system is doing real work: encoding a domain so that invalid states do not compile, building an API whose composition rules are enforced rather than documented, or writing a library that must be generic over the effect type its users bring. That is why the effect and streaming ecosystem - Cats Effect, ZIO, fs2, http4s - grew in Scala and not next door, and why the actor and cluster stack around Akka and Akka Typed did the same. It also earns its cost when the API you must use is Scala's: Spark's native surface is Scala, and the Scala API is where new features land first.

It does not earn its cost on a CRUD service with a small team and a deadline. Kotlin gives you null safety, data classes, and coroutines with javac-class compile times and no binary compatibility matrix; modern Java gives you records, sealed interfaces, and pattern matching for a large fraction of what people historically came to Scala for. The honest test is whether your codebase actually uses the type system - if the answer is "we use case classes and map", a simpler JVM language will do that with less operational overhead. If the answer is "our API is generic in F[_] and the compiler rejects invalid pipelines", nothing else on the JVM offers the trade. Comparisons across the effect libraries are in effect systems compared; non-JVM backends in Scala.js and Scala Native.

Scala is a JVM language whose centre of gravity is its type system, not its syntax. Everything is an expression and everything is an object, which is what lets traits act as modules, functions as values, and ADTs as sealed hierarchies that the compiler can check exhaustively. Immutability is the default and the collections are persistent; contextual abstraction supplies type class instances the caller never writes. The costs are real and structural: local inference that fails in recognisable places, compile times dominated by implicit search and macro expansion, and a binary compatibility matrix in Scala 2 that Scala 3 finally collapses via TASTy. Reach for it when the type system is doing load-bearing work, and reach for Kotlin or modern Java when it is not.