Scala Interview Questions · 2026

Scala Interview Questions (2026): Most Asked, With Answers

A backend engineer interviewing for a payments team at a mid-size Berlin fintech got asked to explain why a case class's equals method worked correctly across a network boundary while the equivalent Java POJO's didn't. He knew Scala well enough to use it daily, but he'd never had to articulate that case classes generate structural equality from the constructor fields automatically, no override needed. He got the job anyway, on the strength of everything else, but he told me afterward that question stuck with him more than anything about actors or futures. Scala interviews reward exactly that kind of thing: knowing why the language does what it does, not just which keyword to reach for.

Scala sits in an odd spot in the industry. It's not a top-ten language by raw adoption, the Stack Overflow Developer Survey has consistently placed it well behind Java, Kotlin, and even Clojure in the "used" column (Stack Overflow Developer Survey, 2025), but it's load-bearing at a disproportionate number of large-scale backend and data platforms. Spotify's backend, large chunks of Morgan Stanley's trading infrastructure, and most of the Apache Spark ecosystem run on it. That combination, small hiring pool, high-stakes systems, means Scala interviews tend to go deeper per topic than a broader-market language interview would, and they lean harder on the functional programming half of the language than the object-oriented half.

This page covers Scala interview questions across eight areas: case classes and pattern matching, functional programming fundamentals (immutability, higher-order functions, Option), the type system (variance, implicits and givens, type classes), collections, concurrency with Future and actors, Scala 3's biggest changes, JVM interop and tooling, and the harder edge cases around implicit resolution and path-dependent types that come up in senior loops. Code examples use Scala 3 syntax where it differs meaningfully from Scala 2, noted inline.

50Questions
Pattern Matching & ImmutabilityCore Concept
Scala 3 CodeFormat
Full Bytecode CompatibilityJVM Interop

Case classes, objects, and pattern matching

Almost every Scala loop opens here. It's treated as a warm-up, but a candidate who can't explain what a case class actually generates beneath that rarely makes it much further.

Easy questions

15

A case class gets, for free: an apply method on the companion object so you can construct it without new, structural equals and hashCode based on the constructor parameters, a readable toString, a copy method for making a modified duplicate, and support for pattern matching through an auto-generated unapply. All of that comes from one keyword instead of writing boilerplate by hand.

scala
case class User(id: String, name: String)

val u1 = User("1", "Ada")
val u2 = User("1", "Ada")
u1 == u2 // true, structural equality, not reference equality

val u3 = u1.copy(name = "Ada Lovelace") // new instance, id unchanged

A companion object is an object declared with the same name as a class in the same file, and it gets special access to the class's private members. Scala doesn't have the static keyword at all, everything is an instance of something, so a companion object is where you put factory methods, constants, and utility functions that conceptually belong to the type but don't need an instance to call.

scala
class Circle private (val radius: Double)

object Circle {
 def apply(radius: Double): Circle =
  if radius > 0 then new Circle(radius) else throw new IllegalArgumentException("radius must be positive")
}

val c = Circle(5.0) // calls Circle.apply, no `new` needed

Pattern matching checks a value against a series of shapes, and the compiler can verify exhaustiveness for you when the possible cases are known ahead of time. Sealing a trait tells the compiler that every subtype is defined in the same file, which is exactly what makes that exhaustiveness check possible.

scala
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Square(side: Double) extends Shape

def area(shape: Shape): Double = shape match
 case Circle(r) => math.Pi * r * r
 case Square(s) => s * s
// if a new case gets added to Shape without updating this match,
// the compiler warns you the match isn't exhaustive

A class is a blueprint you instantiate as many times as you want, with new (or a companion's apply). An object declares a singleton directly, exactly one instance ever exists, created lazily the first time it's referenced, and there's no way to create a second one. Scala reaches for singleton objects anywhere Java would use a static utility class or a Java-style singleton pattern, without the awkward private constructor and getInstance boilerplate that pattern usually needs.

An immutable value can't change after it's created, which removes an entire class of bugs where one part of a program mutates shared state that another part didn't expect to change. It also makes reasoning about concurrent code dramatically simpler: an immutable value is automatically safe to share across threads, no locking required, because nothing can ever write to it after construction.

scala
val name = "Ada"
name = "Grace" // compile error, val can't be reassigned

var counter = 0
counter = counter + 1 // fine, var explicitly opts into mutation

The convention on most Scala teams is val everywhere by default, and var only when you have a specific, local, well-understood reason, a tight loop counter, an accumulator inside a single method that never escapes it.

A higher-order function either takes a function as a parameter or returns one, and Scala can do this because functions are values, first-class citizens that can be assigned to a variable, passed as an argument, or returned from another function, exactly like an integer or a string.

scala
def applyTwice(f: Int => Int, x: Int): Int = f(f(x))

val addOne: Int => Int = x => x + 1
applyTwice(addOne, 5) // 7

Option makes the possible absence of a value part of the type itself, Option[User] instead of a User that might secretly be null. That forces every caller to explicitly handle the empty case at compile time, through pattern matching or methods like getOrElse and map, instead of discovering a NullPointerException at runtime somewhere far from where the null actually originated.

scala
def findUser(id: String): Option[User] =
 if id == "1" then Some(User("1", "Ada")) else None

val greeting = findUser("1").map(u => s"Hello, ${u.name}").getOrElse("User not found")

Unit is a real type with exactly one value, written (), used for expressions run purely for their side effect, printing, mutating a var, that don't produce a meaningful result. Java's void isn't a type at all, it's the absence of a return value, which is why you can't write a generic method parameterized over void the way you can write one parameterized over Unit in Scala. Because everything in Scala is an expression, a block that ends in a println still needs some type, and Unit is that type.

In Scala, if is an expression, it evaluates to a value, so you can assign the result of an if/else directly to a val instead of declaring an uninitialized variable outside the block and assigning it inside each branch the way you would in a purely statement-based language. Nearly everything in Scala works this way, match expressions, for-comprehensions, even try/catch, which is part of why Scala code tends to read as fewer, denser expressions rather than sequences of imperative steps.

scala
val label = if age >= 18 then "adult" else "minor" // no var, no uninitialized placeholder

List is a singly-linked list: prepending an element is constant time, but random access by index or updating a middle element is linear time since it has to walk the list. Vector is a tree-based structure with effectively constant-time access, updates, and appends at both ends, at the cost of prepend being slightly slower than List's constant-time prepend. In practice, default to List when you're mostly processing sequentially, head/tail, mostly prepending, and reach for Vector when you need random access or frequent appends on a large collection.

Immutable collections share the same safety argument as immutable values generally: they can be freely shared across threads and passed around without any risk that some other part of the program mutates them out from under you. Scala reaches for the mutable versions, scala.collection.mutable.ListBuffer, ArrayBuffer, and so on, mainly inside a tightly scoped, performance-sensitive local block, building up a large result iteratively before converting it back to an immutable collection at the end.

scala
import scala.collection.mutable.ListBuffer

def buildResult(n: Int): List[Int] = {
 val buffer = ListBuffer[Int]()
 for i <- 1 to n do buffer += i * i
 buffer.toList // convert back to immutable before returning
}

Seq is a trait describing an ordered sequence in general, without committing to a specific implementation. List is one concrete implementation of Seq, the singly-linked one. A method signature that accepts Seq[A] instead of List[A] is more flexible for callers, since it accepts a List, a Vector, or anything else that's Seq-shaped, without forcing the caller to convert to one specific concrete type just to call your function.

A Set guarantees no duplicate elements and gives you near-constant-time membership checks with contains, backed by a hash table for the default immutable Set. A List allows duplicates, preserves insertion order, and checking membership is linear time since it has to walk the whole list. Reach for Set specifically when "does this collection contain X" is a question you'll ask often, and when the order elements were added in doesn't matter to your logic.

A Future represents a value that will become available at some point, computed asynchronously on some thread from an ExecutionContext rather than blocking the calling thread. The biggest misconception is that creating a Future somehow defers the work until later, the way a lazy val does. It doesn't. The block inside Future {... } starts running essentially immediately, on a separate thread, the moment the Future is constructed.

scala
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

val f = Future {
 println("running now, not later")
 42
}
// the println already happened by the time this line runs,
// assuming the thread pool had capacity available

No. Scala 3 added top-level definitions, so a function, val, or even a @main-annotated entry point can live directly in a file with no enclosing object required. @main specifically marks a top-level function as a program's entry point, and the compiler generates the equivalent of the old object-with-main boilerplate behind the scenes.

scala
// Scala 3, no enclosing object needed
@main def run(name: String): Unit =
 println(s"Hello, $name")

Medium questions

24

A guard is an extra boolean condition attached to a case with if, evaluated only after the shape of the pattern already matched. You reach for one when the pattern itself isn't enough to express the condition, for example matching a specific range of values inside a case class rather than just its shape.

scala
def classify(age: Int): String = age match
 case a if a < 13 => "child"
 case a if a < 20 => "teenager"
 case a if a < 65 => "adult"
 case _      => "senior"

A class can extend multiple traits but only one abstract class, since Scala doesn't allow multiple inheritance of constructor parameters or state from more than one parent class. Traits also can't take constructor parameters in the way a class can (Scala 3 relaxed this somewhat, but the core distinction holds). Abstract classes exist mainly for Java interop, and for cases where you genuinely need constructor parameters shared through inheritance rather than mixed in.

scala
trait Flyable { def fly(): String = "flying" }
trait Swimmable { def swim(): String = "swimming" }

class Duck extends Flyable with Swimmable
val d = Duck()
d.fly() // "flying"
d.swim() // "swimming", both traits mixed in

My rule of thumb, and most style guides land here too: default to trait unless you have a specific reason, Java interop, or constructor parameters that need to be shared down a single inheritance chain, to reach for an abstract class instead.

map applies a function to every element and returns a collection of the same shape with transformed elements. filter keeps only the elements that satisfy a predicate. flatMap is the one that trips candidates up: it applies a function that itself returns a collection for each element, then flattens all those inner collections into a single one, instead of ending up with a collection of collections the way map would.

scala
val nums = List(1, 2, 3)

nums.map(x => List(x, x * 10))
// List(List(1, 10), List(2, 20), List(3, 30))

nums.flatMap(x => List(x, x * 10))
// List(1, 10, 2, 20, 3, 30), flattened

A for-comprehension is syntax sugar. The compiler rewrites it into a chain of map, flatMap, and withFilter calls, and it works over anything that implements those methods, not just collections. That's why the exact same syntax works over List, Option, Future, and Either without any special-casing.

scala
val result = for
 a <- Option(2)
 b <- Option(3)
yield a + b
// desugars to: Option(2).flatMap(a => Option(3).map(b => a + b))
// result: Some(5)

Understanding the desugaring matters because a for-comprehension that mixes types that don't have compatible flatMap signatures fails to compile in a way that's genuinely confusing if you think of it as a magic loop instead of what it actually is.

All three combine a collection's elements into a single result using an accumulator, but foldLeft processes left to right and foldRight processes right to left. For an operation that's associative and commutative, sum or product, the direction is invisible in the result. It stops being invisible the moment the operation isn't commutative, string concatenation in a specific order, or building up a data structure where order is part of the meaning.

scala
List(1, 2, 3).foldLeft("")((acc, x) => acc + x.toString) // "123"
List(1, 2, 3).foldRight("")((x, acc) => x.toString + acc) // "123", same here
List("a", "b", "c").foldLeft("")((acc, x) => acc + x)   // "abc"
List("a", "b", "c").foldRight("")((x, acc) => x + acc)   // "abc"

Where it genuinely diverges is stack behavior on very large collections: foldLeft is tail-recursive and safe on huge lists, foldRight on a naive implementation can stack overflow on a large enough input since it has to recurse all the way to the end before it can start combining.

Variance describes how subtyping relationships between a container's contents translate to subtyping relationships between the containers themselves. +A marks a type parameter covariant, meaning if Dog is a subtype of Animal, then List[Dog] is a subtype of List[Animal]. That's safe for an immutable, read-only collection because you can only ever read Animals out of it. It would be unsafe for a mutable collection, since you could then insert a Cat into what's actually backed by a List[Dog].

scala
class Animal
class Dog extends Animal

val dogs: List[Dog] = List(Dog(), Dog())
val animals: List[Animal] = dogs // fine, List is covariant

An implicit parameter lets the compiler fill in an argument automatically from the surrounding scope, instead of every call site having to pass it explicitly. It's how Scala threads things like an ExecutionContext through a codebase without every single function call needing to mention it by hand. Scala 3 replaced the implicit keyword for this specific use with given and using, which read more clearly and separate "this parameter is implicit" from the several other unrelated things implicit used to mean in Scala 2.

scala
// Scala 2 style
def logMessage(msg: String)(implicit ec: ExecutionContext): Unit = ???

// Scala 3 style
def logMessage(msg: String)(using ec: ExecutionContext): Unit = ???

given ExecutionContext = scala.concurrent.ExecutionContext.global
logMessage("hello") // ec supplied automatically from the given in scope

Any is the supertype of every type in Scala, both AnyVal (value types like Int and Boolean) and AnyRef (reference types, roughly Java's Object). Nothing is the subtype of every type, and no value ever actually exists with type Nothing. It shows up as the return type of a function that never returns normally, one that always throws, and it's what lets None and an exception-throwing branch type-check inside a match where the other branches return some concrete type, since Nothing is a subtype of everything.

scala
def fail(msg: String): Nothing = throw new RuntimeException(msg)

def divide(a: Int, b: Int): Int =
 if b == 0 then fail("division by zero") else a / b
// fail returns Nothing, which is a subtype of Int, so this type-checks

Any is the universal supertype. Below it, the hierarchy splits into AnyVal, the value types that map onto the JVM's primitives, Int, Boolean, Double, Char, and AnyRef, which covers every reference type, roughly equivalent to Java's Object. The split exists because value types can be stored unboxed on the JVM for performance, while reference types are always heap-allocated objects, and Scala's type system models that JVM-level distinction explicitly instead of hiding it the way some other JVM languages do.

A def re-evaluates its body every single time it's called. A val evaluates its body exactly once, immediately, at the point where it's declared. A lazy val defers that single evaluation until the first time it's actually accessed, then caches the result for every access after that.

scala
def now = System.nanoTime()   // different value every call
val fixed = System.nanoTime()  // computed once, at declaration
lazy val deferred = System.nanoTime() // computed once, at first access

println(now) // some value
println(now) // different value
println(fixed) // same value forever
println(deferred) // computed the moment this line runs, not before

LazyList (called Stream before Scala 2.13) computes its elements on demand and caches them once computed, instead of evaluating every element up front the way a List does. That makes it possible to represent an infinite sequence, or a sequence whose later elements are expensive to compute and might never actually be needed, since you only pay the cost for the elements you actually consume.

scala
val fibs: LazyList[Int] = 0 #:: 1 #:: fibs.zip(fibs.tail).map(_ + _)
fibs.take(10).toList // List(0, 1, 1, 2, 3, 5, 8, 13, 21, 34)
// the infinite fibs LazyList never actually computes past what.take needs

Array is Scala's wrapper around the JVM's native array type, which means it's mutable, fixed-size, and reflects Java array semantics directly rather than participating in Scala's own immutable collection hierarchy. That's also why Array can't be pattern-matched or compared with == the way other Scala collections can without extra care, == on two arrays with identical contents checks reference equality, not structural equality, since it delegates straight to the underlying Java array's equals.

scala
val a1 = Array(1, 2, 3)
val a2 = Array(1, 2, 3)
a1 == a2     // false, reference equality
a1.sameElements(a2) // true, structural comparison

An ExecutionContext is the thread pool a Future's callbacks and computations actually run on. It's passed implicitly because almost every operation on a Future, mapping over its result, chaining another async call, needs to know which thread pool to schedule that work on, and threading it through every call explicitly would be tedious noise. scala.concurrent.ExecutionContext.Implicits.global is a fork-join pool sized to the number of available processors, convenient for scripts and simple apps, but production services usually configure a dedicated pool sized for their actual workload, since blocking calls on the global pool can starve unrelated work.

An actor is an isolated unit that holds its own private state, processes one message at a time from its mailbox, and communicates with other actors only by sending messages, never by directly touching another actor's state. Because each actor processes messages one at a time, sequentially, you get thread-safety for that actor's own state without ever writing a lock, avoiding the entire category of race conditions and deadlocks that shared-memory, lock-based concurrency invites.

scala
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors

object Counter {
 def apply(count: Int = 0): Behavior[String] = Behaviors.receiveMessage {
  case "increment" => Counter(count + 1)
  case "get"    => println(s"count is $count"); Behaviors.same
 }
}

recover catches a failed Future's exception and replaces it with a plain value, computed synchronously from a partial function over the exception. recoverWith does the same thing but lets the recovery itself return another Future, useful when recovering from a failure means retrying an async operation rather than just falling back to a static default.

scala
val primary: Future[String] = Future.failed(new RuntimeException("timeout"))

val withDefault = primary.recover { case _: RuntimeException => "default value" }

val withFallbackCall = primary.recoverWith {
 case _: RuntimeException => callBackupService() // another Future[String]
}

Await.result blocks the calling thread until the Future completes, which throws away the entire point of using Future in the first place, letting other work proceed while you wait. Calling it anywhere on a request-handling thread in a server can tie up threads under load exactly when you need them free, turning a scalability feature into a bottleneck. The better pattern is staying inside Future's own combinators, map, flatMap, recover, all the way out to the edge of the program, and only ever blocking, if at all, in a top-level main method or a test.

An extension method lets you add a method to an existing type without modifying its source or wrapping it in a new class, and it's called with normal dot syntax as if it had been defined on the type directly. Scala 2 achieved the same effect with implicit classes, a wrapper class marked implicit that the compiler would construct on the fly, which worked but read as an odd, indirect pattern for something conceptually simple.

scala
extension (s: String)
 def shout: String = s.toUpperCase + "!"

"hello".shout // "HELLO!"

A union type, written A | B, describes a value that is either an A or a B, without requiring the two types to share any common parent trait you had to define ahead of time. That's the practical advantage over a sealed hierarchy: you can combine types you don't control, from a library, from the standard library itself, into a union on the fly, whereas a sealed trait only works for types you designed to be part of that hierarchy from the start.

scala
def stringify(value: Int | String): String = value match
 case i: Int  => s"int: $i"
 case s: String => s"string: $s"

stringify(42)   // "int: 42"
stringify("hello") // "string: hello"

Scala 3's enum keyword generates roughly what a sealed trait with case objects and case classes used to require by hand, cases with no parameters become singleton values automatically, cases can also carry parameters like a case class, and the compiler still gives you exhaustiveness checking in a match. It's meaningfully less boilerplate than writing the sealed trait pattern out manually, and it interops cleanly with Java enums when that matters.

scala
enum Status:
 case Pending, Shipped, Cancelled

enum Shape:
 case Circle(radius: Double)
 case Square(side: Double)

def area(s: Shape): Double = s match
 case Shape.Circle(r) => math.Pi * r * r
 case Shape.Square(s) => s * s

Full interop with the entire Java ecosystem: you can call any Java library directly from Scala, and Java code can call most Scala code back, since it all compiles down to ordinary class files running on the same JVM. It leaks in a few predictable places: Scala's richer type-level features, like traits with fields or advanced generics, sometimes generate less obvious bytecode than a straightforward Java class would, and calling into Java's checked-exception model from Scala (which has no checked exceptions) means you're responsible for knowing which Java calls can throw, since the compiler won't force you to handle them.

Try specifically wraps code that might throw an exception, catching it and representing the outcome as Success(value) or Failure(exception), so it's the natural fit around code you don't control, a Java library call, parsing input, anything already built around throwing. Either[E, A] is more general and doesn't require an actual exception at all, you choose the error type E yourself, which is the better fit for your own domain logic where you want a specific, typed error rather than whatever exception type happened to get thrown.

scala
import scala.util.Try

def parseAge(s: String): Try[Int] = Try(s.toInt)

def validateAge(age: Int): Either[String, Int] =
 if age >= 0 && age < 150 then Right(age) else Left(s"invalid age: $age")

sbt is Scala's standard build tool, handling dependency resolution, incremental compilation, and running tests, structured around a Scala DSL rather than XML or a Groovy-based config. The first build feels slow mainly because of JVM and sbt's own startup cost combined with the Scala compiler itself being noticeably heavier than javac, since it's doing considerably more type inference and implicit resolution work per file. Incremental compilation, only recompiling files that actually changed and whatever depends on them, is what makes the second and later builds in a session feel closer to instant.

== in Scala calls equals, so for a case class it performs structural equality, comparing the actual field values. eq is reference equality, true only if both sides point at the literal same object in memory, the same as Java's == on reference types. Case classes get sensible == for free through their generated equals; a plain class without an overridden equals falls back to reference equality for == too, since Scala's default equals implementation is inherited straight from AnyRef.

scala
case class Point(x: Int, y: Int)
val p1 = Point(1, 2)
val p2 = Point(1, 2)
p1 == p2  // true, structural
p1.eq(p2) // false, different objects in memory

The REPL, started with scala or sbt console, evaluates expressions one at a time interactively and prints their inferred type and value immediately, useful for checking exactly what type something infers to or quickly sanity-checking a small snippet without setting up a full project. sbt run compiles and runs your actual project's entry point, the thing you'd use for the real program. In a live coding interview, the REPL is worth reaching for specifically to double-check an inferred type you're not fully sure of before committing to it in the main solution.

Hard questions

11

unapply is the method that makes an object usable on the left side of a pattern match. When you write case User(id, name) =>, the compiler calls User.unapply(value) behind the scenes, which returns an Option wrapping the extracted fields if the match succeeds, or None if it doesn't. Case classes get this generated automatically, but you can write a custom extractor object anywhere you want pattern-matching syntax over something that isn't a case class at all.

scala
object Even {
 def unapply(n: Int): Option[Int] = if n % 2 == 0 then Some(n) else None
}

def describe(n: Int): String = n match
 case Even(value) => s"$value is even"
 case _      => s"$n is odd"

This is the mechanism that makes regex matching, list deconstruction (x :: xs), and every custom extractor work through the exact same syntax. Once you see that they're all just calling unapply, pattern matching stops feeling like special-cased magic.

Contravariance, marked with -A, flips the subtyping direction: if Dog is a subtype of Animal, a Function1[Animal, _] is a subtype of Function1[Dog, _]. That feels backwards until you think about substitutability: a function that can handle any Animal can safely be used anywhere a function that only needs to handle a Dog is expected, since any Dog is also an Animal. Scala's Function1[-T, +R] is contravariant in its parameter type and covariant in its return type for exactly this reason.

scala
class Animal { def name = "animal" }
class Dog extends Animal { override def name = "dog" }

val printAnimal: Animal => String = a => s"I am a ${a.name}"
val printDog: Dog => String = printAnimal // legal, printAnimal handles Dog just fine

A type class is a pattern for adding behavior to a type without modifying that type's own definition or forcing it into an inheritance hierarchy. You define a trait describing the behavior, provide implicit (or given) instances of that trait for each concrete type you want to support, and write generic functions that require an instance of the type class as an implicit parameter.

scala
trait JsonEncoder[A] {
 def encode(value: A): String
}

given JsonEncoder[Int] with
 def encode(value: Int): String = value.toString

given JsonEncoder[String] with
 def encode(value: String): String = s""""$value""""

def toJson[A](value: A)(using encoder: JsonEncoder[A]): String =
 encoder.encode(value)

toJson(42)   // "42"
toJson("hello") // ""hello""

The payoff is that you can add a JsonEncoder instance for a type from a library you don't control and can't modify, something impossible with an inheritance-based approach where the type has to implement an interface itself.

A self type is a trait annotation declaring that the trait can only be mixed into a class that also satisfies some other type, without actually extending that type in the inheritance chain. It's used for dependency wiring, like the cake pattern, where a trait needs to call methods on another component but shouldn't itself extend that component, since extending it would force a specific inheritance relationship rather than just requiring the capability be present somewhere in the mix.

scala
trait UserRepository { def findUser(id: String): Option[String] }

trait UserService {
 self: UserRepository => // requires UserRepository, doesn't extend it
 def greet(id: String): String =
  findUser(id).map(name => s"Hello, $name").getOrElse("Unknown user")
}

class App extends UserService with UserRepository {
 def findUser(id: String) = if id == "1" then Some("Ada") else None
}

A path-dependent type ties an inner type to the specific instance of an outer class it belongs to, so outer1.Inner and outer2.Inner are treated as genuinely different types even though they're both instances of the same inner class definition. This shows up when a nested type carries meaning that's specific to its enclosing instance, a database connection's cursor type tied to that specific connection, for instance.

scala
class Bank {
 class Account(val balance: Int)
 def openAccount(initial: Int): Account = new Account(initial)
}

val bankA = new Bank
val bankB = new Bank
val accountA: bankA.Account = bankA.openAccount(100)
val accountB: bankB.Account = bankB.openAccount(50)

def deposit(acc: bankA.Account, amount: Int): bankA.Account = ???
deposit(accountA, 10) // fine
deposit(accountB, 10) // compile error, bankB.Account isn't bankA.Account

Most Scala codebases never need this deliberately, but it's worth understanding because it explains some genuinely confusing compiler errors that show up when nested classes leak out of methods without you meaning them to.

@tailrec asks the compiler to verify that a recursive method is actually a tail call, meaning the recursive call is the very last thing that happens, with no pending work left after it returns, so the compiler can rewrite it into a loop instead of consuming a new stack frame per call. It fails to compile if the recursive call isn't in tail position, for example if there's still an operation waiting to be applied to the result of the recursive call after it returns.

scala
import scala.annotation.tailrec

@tailrec
def sumTo(n: Int, acc: Int = 0): Int =
 if n == 0 then acc else sumTo(n - 1, acc + n) // tail position, compiles

def sumToBroken(n: Int): Int =
 if n == 0 then 0 else n + sumToBroken(n - 1) // not tail position, addition happens after the call returns
// annotating sumToBroken with @tailrec would fail to compile

Without @tailrec optimizing this into a loop, a deep enough call chain overflows the JVM stack. Annotating a function that you believe is tail-recursive catches the mistake at compile time instead of finding out at runtime on a large enough input, which is the entire point of adding the annotation in the first place.

The default global ExecutionContext is a fork-join pool sized around your CPU core count, tuned for CPU-bound, non-blocking work. A blocking call ties up one of those limited threads doing nothing useful for the duration of the block, and if enough blocking calls pile up at once, they can starve the pool entirely, stalling unrelated CPU-bound work that has nothing to do with the blocking call. The fix is running blocking work on a separate, dedicated thread pool sized for blocking I/O, not the default pool meant for CPU work.

scala
import scala.concurrent.{ExecutionContext, Future}
import java.util.concurrent.Executors

val blockingPool: ExecutionContext =
 ExecutionContext.fromExecutor(Executors.newFixedThreadPool(50))

def queryDatabase(): Future[String] = Future {
 Thread.sleep(200) // stand-in for a real blocking JDBC call
 "result"
}(blockingPool)

given instances in Scala 3 are found by type during implicit resolution the same way implicit val and implicit def were in Scala 2, but the syntax separates the concept cleanly: a plain given provides a fixed instance, and a given with parameters (the equivalent of an implicit def that itself requires other implicits) derives an instance conditionally, for example deriving a JsonEncoder[List[A]] from an existing JsonEncoder[A].

scala
trait JsonEncoder[A] { def encode(a: A): String }

given JsonEncoder[Int] with
 def encode(a: Int) = a.toString

given [A](using enc: JsonEncoder[A]): JsonEncoder[List[A]] with
 def encode(list: List[A]) = list.map(enc.encode).mkString("[", ",", "]")

def toJson[A](a: A)(using enc: JsonEncoder[A]): String = enc.encode(a)
toJson(List(1, 2, 3)) // "[1,2,3]", derived automatically

A plain type alias is completely transparent, type UserId = String is just another name for String, so nothing stops you from passing a raw string where a UserId was intended, or vice versa. An opaque type hides that underlying representation outside the module where it's defined, so from the caller's perspective it's a genuinely distinct type that the compiler enforces, while inside the defining module it's still treated as the underlying type at zero runtime cost, no wrapper object gets allocated.

scala
object Ids:
 opaque type UserId = String
 def UserId(s: String): UserId = s
 extension (id: UserId) def value: String = id

import Ids.*
val id: UserId = UserId("abc123")
val raw: String = id // compile error outside Ids, UserId isn't visibly a String here

An implicit conversion in Scala 2 was just any implicit def with the right shape, one argument in, a different type out, which meant the compiler could silently insert a conversion anywhere that shape happened to match, sometimes in places nobody intended. Scala 3 requires an explicit given Conversion[A, B] instance and, by default, warns or errors on implicit conversions unless the file explicitly imports scala.language.implicitConversions, which makes an intentional, narrow feature out of something that used to be an easy source of surprising, hard-to-trace behavior.

scala
import scala.language.implicitConversions

case class Celsius(value: Double)
case class Fahrenheit(value: Double)

given Conversion[Celsius, Fahrenheit] = c => Fahrenheit(c.value * 9 / 5 + 32)

val temp: Fahrenheit = Celsius(100) // converted automatically

When a class mixes in several traits that all define the same method, Scala resolves the conflict through linearization, a deterministic ordering of the trait hierarchy computed right to left through the extends... with... with chain, where each trait's super call refers to whichever trait comes next in that linearized order, not necessarily its own literal parent in the source.

scala
trait A { def greet = "A" }
trait B extends A { override def greet = "B -> " + super.greet }
trait C extends A { override def greet = "C -> " + super.greet }
class D extends B with C

new D().greet // "B -> C -> A", C.super resolves to A per linearization, not B calling A directly

This is genuinely one of the trickier things in the language to reason about from first principles, and it rarely comes up in day-to-day code, most teams keep their trait hierarchies shallow enough that it never matters, but a senior candidate who's read one confusing stack trace caused by it tends to remember the mechanism for good.

How to prepare for a Scala interview in 2026

Skip re-reading the syntax reference for the fifth time. Build one small thing that forces you to actually use the type system on purpose: a type-class-based JSON encoder for two or three of your own case classes, a tiny state machine modeled with a Scala 3 enum and exhaustive pattern matching, or a Future-based pipeline that deliberately separates CPU-bound work from a blocking database call onto two different execution contexts. Watching your own code fail to compile because a match isn't exhaustive, or because you tried to pass a raw String where an opaque UserId was expected, teaches the type system faster than reading about it does.

Across the backend mock interviews we run through LastRoundAI, the trait linearization and implicit-resolution-order questions trip up more otherwise-strong candidates than the actor model questions do, likely because most people learn actors from a tutorial that walks through the exact happy path, while implicit resolution is something you mostly absorb from confusing compiler errors on the job, not from a clean example. It's a consistent enough pattern to flag, though we don't track it as a precise percentage.

One practical note for 2026: if the role touches Spark, most production clusters have finished, or are actively finishing, the move to Spark 3.x with Scala 2.13, and a growing number of newer services are written against Scala 3 directly, particularly at companies that adopted the language recently rather than carrying an older Scala 2.12 codebase forward. Ask directly which major version the team runs. The answer changes which implicit syntax and collection APIs are actually worth brushing up on before the interview.

Get the reps in before the real thing

Explaining trait linearization on a whiteboard is not the same as defending it out loud once an interviewer changes the mixin order and asks what the new output is. LastRoundAI's mock interview mode runs live coding rounds with real-time follow-up questions in your browser, and the free plan includes 15 credits a month that reset monthly rather than piling up unused. Starter is $29/mo (or $19/mo on the 3-month plan) if a handful of sessions isn't enough runway.

Once your answers hold up under a follow-up, the slower part of the job hunt is usually just getting in front of enough backend and data-platform roles that actually test Scala instead of treating it as a line on a job description nobody checks for. Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

How this list was built

Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.

What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.

If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

Frequently asked questions

How long does it take to prepare for a Scala interview?

If you already work with Scala day to day, a focused week on the areas you avoid in practice is usually enough. Coming in cold, expect three to four weeks. The gap is rarely knowledge; it is being able to explain something you normally just use.

What Scala topics come up most often?

Interviewers concentrate on the parts that cause production incidents rather than the parts that are pleasant to learn. Expect the fundamentals to be assumed and the follow-up questions to sit one layer below what a tutorial covers.

Do I need hands-on Scala experience to pass?

It shows quickly either way. Textbook answers hold up until the interviewer asks what you did when it broke, and that is usually the question that separates candidates. A small real project you can discuss honestly beats a longer list of familiarity claims.

Is Scala still worth learning in 2026?

For interview purposes the question is really whether the teams you are targeting use it, which is worth checking against their actual job postings rather than general popularity rankings. Where it is in use it tends to be deeply embedded and slow to replace.

Leave a Reply

Your email address will not be published. Required fields are marked *