One of the cornerstones of software engineering is the Abstract Data Type (ADT):
define a type solely by its name, its constructors, and its operations — while hiding the
concrete implementation. In Scala 3 the opaque type construct makes this possible
with zero runtime overhead.
opaque type T = TImpl creates a brand-new type that, outside its defining
module, is completely distinct from TImpl. No boxing, no wrapping — the
compiler treats them as different types, but at runtime they are the same representation.
An opaque type inside an object gives you a self-contained ADT module:
the object defines the type, the constructors, and the operations. Clients see only the public
API. This is an alternative to algebraic data types (enum / case class)
that promotes information hiding from the start.
Be ready to compare opaque-type ADTs with Scala’s
enum algebraic data types. When does each style shine? Opaque types favour
modularity and make it natural to evolve toward type classes; enums make it easy to add
operations outside the definition.
Here is a classic “primitive obsession” cure: an opaque type for Euro amounts.
Why is val e3: Euro = 100 forbidden? Because Euro is opaque —
outside the EuroADT object, the compiler treats it as unrelated to Int.
This forces all construction through the provided factory functions, preserving invariants.
The same pattern works for recursive data structures. A linked-list Sequence
is defined as an opaque alias to a private enum inside the module.
object SequenceADT:
private enum SequenceImpl[E]:
case Cons(head: E, tail: Sequence[E])
case Nil()
import SequenceImpl.*
opaque type Sequence[A] = SequenceImpl[A]
def cons[E](head: E, tail: Sequence[E]): Sequence[E] = Cons(head, tail)
def nil[E](): Sequence[E] = Nil[E]()
extension [A](seq: Sequence[A])
def map[B](mapper: A => B): Sequence[B] = seq match
case Cons(h, t) => Cons(mapper(h), t.map(mapper))
case Nil() => Nil()
def filter(p: A => Boolean): Sequence[A] = seq match
case Cons(h, t) if p(h) => Cons(h, t.filter(p))
case Cons(_, t) => t.filter(p)
case Nil() => Nil()
@main def trySequenceADT =
import SequenceADT.*
val seq = cons(10, cons(20, cons(30, nil())))
println(seq.map(_ + 1))
The private enum is the “real” data type. The opaque type Sequence[A] = SequenceImpl[A]
hides this from clients. Operations like map and filter are exposed as
extension methods.
Because Set is just an opaque alias for Sequence, we can build set
operations on top of the sequence operations — all while hiding that fact from users.
object SetADT:
opaque type Set[A] = Sequence[A]
def fromSequence[A](s: Sequence[A]): Set[A] = s match
case Cons(h, t) => Cons(h, fromSequence(t.remove(h)))
case Nil() => Nil()
def union[A](s1: Set[A], s2: Set[A]): Set[A] = s2 match
case Cons(h, t) => Cons(h, union(s1.remove(h), t))
case Nil() => s1
def intersection[A](s1: Set[A], s2: Set[A]): Set[A] = s1 match
case Cons(h, t) if s2.contains(h) => Cons(h, intersection(t, s2.remove(h)))
case Cons(_, t) => intersection(t, s2)
case Nil() => Nil()
extension [A](s: Set[A])
def contains(a: A): Boolean = s match
case Cons(h, t) if h == a => true
case Cons(_, t) => t.contains(a)
case Nil() => false
def remove(a: A): Set[A] = s.filter(_ != a)
def toSequence(): Sequence[A] = s
Notice remove delegates to filter from the Sequence module.
Since Set[A] is just Sequence[A] at runtime, all sequence
operations are available inside the module. Clients, however, cannot accidentally misuse
a Set as a Sequence.
Just as we gave data types an abstract interface via opaque types, we can give modules an abstract interface via traits. A trait declaring method signatures becomes a module type; any object implementing it is a module that conforms to that type.
trait ModuleType { def meth(...): ... } defines a module interface.
object Impl extends ModuleType { ... } provides an implementation.
Functions can then accept ModuleType as a parameter, enabling strategy-like
polymorphism in a purely functional setting.
This approach respects the Open/Closed Principle (extend behaviour by providing new implementations) and the Dependency Inversion Principle (depend on abstractions, not concretions) without any OOP inheritance machinery.
Two implementations of a simple logger trait demonstrate how module types enable pluggable behaviour:
object LoggerModules:
trait LoggerModuleType:
def log(s: String): Unit
object BasicLoggerModule extends LoggerModuleType:
def log(s: String): Unit = println(s)
object SilentLoggerModule extends LoggerModuleType:
def log(s: String): Unit = {}
def loggedCall[A, B](name: String, f: A => B, a: A)(LoggerModule: LoggerModuleType): B =
LoggerModule.log(s"calling function $name with input $a")
f(a)
@main def tryLoggers =
import LoggerModules.*
println(loggedCall("sin", Math.sin(_), 0.5)(BasicLoggerModule))
println(loggedCall("sin", Math.sin(_), 0.5)(SilentLoggerModule))
How does loggedCall achieve OCP-compliant behaviour? You can add a new
logger (e.g. a file-logger) without modifying loggedCall at all — just
pass a new object that extends LoggerModuleType.
A more realistic example: a math module providing factorial and exponentiation, with two distinct implementations (simple recursive vs. tail-recursive with input validation).
object BasicMathModule extends MathModuleType:
def factorial(n: Int): Int =
if n == 0 then 1 else n * factorial(n - 1)
def exp(base: Double, power: Int): Double =
if power == 0 then 1 else base * exp(base, power - 1)
object ProductionMathModule extends MathModuleType:
def factorial(n: Int): Int =
@scala.annotation.tailrec
def _fact(n: Int, temp: Int): Int =
if n == 0 then temp else _fact(n - 1, temp * n)
n match
case _ if n >= 0 => _fact(n, 1)
def exp(base: Double, power: Int): Double =
@scala.annotation.tailrec
def _exp(p: Int, temp: Double): Double =
if p == 0 then temp else _exp(p - 1, temp * base)
power match
case _ if base >= 0 => _exp(power, 1)
A binomial probability function uses whichever module is passed, proving the abstraction:
def binomialProbability(mm: MathModuleType)(n: Int, x: Int, p: Double): Double =
mm.factorial(n) / mm.factorial(x) / mm.factorial(n - x) *
mm.exp(p, x) * mm.exp(1 - p, n - x)
// Prob. of 6 heads on 10 coin tosses, same result from both modules:
binomialProbability(BasicMathModule)(10, 6, 0.5)
binomialProbability(ProductionMathModule)(10, 6, 0.5)
Contextual abstraction is the idea that certain function inputs are really just
“context” — configuration, strategies, dependencies that the caller
should not have to pass explicitly every time. Scala 3’s given/using
mechanism provides first-class support for this pattern.
using clauses mark parameters as contextual; given definitions
provide canonical values for those parameters. The compiler resolves them automatically
at the call site.
Here is the simplest case: a max function over a sequence that needs a
“default” value when the sequence is empty:
object ContextualParameters:
def max(seq: Sequence[Int])(using orElse: Int): Int = seq match
case Cons(h1, Cons(h2, t)) => max(Cons(if h1 > h2 then h1 else h2, t))
case Cons(h1, Nil()) => h1
case _ => orElse
given standardDefault: Int = -1
@main def tryContextualParameters() =
import ContextualParameters.*
println(max(Cons(10, Cons(30, Cons(20, Nil()))))(using -1)) // explicit
println(max(Nil())(using -1))
import ContextualParameters.given
println(max(Cons(10, Cons(30, Cons(20, Nil()))))) // implicit via given
println(max(Nil())) // implicit via given
The same mechanism can inject a comparison strategy into max:
object ContextualStrategies:
def max(seq: Sequence[Int])(using ordering: (Int, Int) => Boolean): Int = seq match
case Cons(h1, Cons(h2, t)) =>
max(Cons(if ordering(h1, h2) then h1 else h2, t))
case Cons(h1, Nil()) => h1
given ((Int, Int) => Boolean) = _ > _
The full power emerges when we combine module types with given/using:
a module is “injected” into a function as a contextual parameter.
object ContextualModules:
trait IntOrderingModuleType:
def greater(t1: Int, t2: Int): Boolean
def max(seq: Sequence[Int])(using ordering: IntOrderingModuleType): Int = seq match
case Cons(h1, Cons(h2, t)) =>
max(Cons(if ordering.greater(h1, h2) then h1 else h2, t))
case Cons(h1, Nil()) => h1
object MyIntOrderingModule extends IntOrderingModuleType:
def greater(t1: Int, t2: Int): Boolean = t1 > t2
given iom: IntOrderingModuleType = MyIntOrderingModule
@main def tryContextualModules =
import ContextualModules.*
import ContextualModules.given // import all givens
println(max(Cons(10, Cons(30, Cons(20, Nil())))))
val ord = summon[IntOrderingModuleType] // recovers the given
Applying this to the MathModuleType from earlier: we can declare a
given MathModuleType = ProductionMathModule and then call
binomialProbability without explicitly passing the module.
def binomialProbability(n: Int, x: Int, p: Double)(using mm: MathModuleType): Double =
mm.factorial(n) / mm.factorial(x) / mm.factorial(n - x) *
mm.exp(p, x) * mm.exp(1 - p, n - x)
given MathModuleType = ProductionMathModule
// Usage: no module argument needed!
binomialProbability(10, 6, 0.5)
This is pure-FP dependency injection. How is it different from the
OOP approach (DI frameworks like Spring)? There is no reflection, no container, no
runtime wiring — the compiler resolves everything at compile time based on scoped
given definitions.
When a module type is generic — parameterised by a type T —
and used as a contextual parameter, it becomes a type class. A context bound
[T: TypeClass] says: “this function works for any T for which
a TypeClass[T] witness is available in scope.”
def f[T: TypeClass](x: T): ... is syntactic sugar for
def f[T](x: T)(using TypeClass[T]): .... Inside the method body, call
summon[TypeClass[T]] to recover the witness.
This is ad-hoc polymorphism: different types can have completely different
implementations of the same interface, chosen not by inheritance but by which
given instance is in scope.
trait Ordered[T]:
def greater(t1: T, t2: T): Boolean
// Context-bound style
def max[T: Ordered](seq: Sequence[T]): T = seq match
case Cons(h1, Cons(h2, t)) =>
max(Cons(if summon[Ordered[T]].greater(h1, h2) then h1 else h2, t))
case Cons(h1, Nil()) => h1
// Given instances
given Ordered[Int] with
def greater(t1: Int, t2: Int): Boolean = t1 > t2
object MyOrderedString extends Ordered[String]:
def greater(t1: String, t2: String): Boolean = t1 > t2
given Ordered[String] = MyOrderedString
@main def tryContextBound =
println(max(Cons(10, Cons(30, Cons(20, Nil()))))) // Int: works
println(max(Cons("a", Cons("c", Cons("d", Nil()))))) // String: works
// println(max(Cons(1.0, Cons(3.0, Cons(5.0, Nil()))))) // Double: no given!
The Showable type class provides a show method for any type we
choose to support. It mimics what .toString does, but in a principled,
extensible way.
trait Showable[T]:
def show(t: T): String
// Extension method for all Showable types
extension [A: Showable](a: A)
def show(): String = summon[Showable[A]].show(a)
def showPair[A: Showable, B: Showable](t: (A, B)): String = t match
case (a, b) => "(" + a.show() + ", " + b.show() + ")"
def showSequence[A: Showable](seq: Sequence[A]): String = seq match
case Cons(h, t) => "| " + h + showSequence(t)
case Nil() => ":"
// Given instances
given Showable[Int] with
def show(i: Int): String = "" + i
given Showable[String] with
def show(s: String): String = s
case class Student(name: String, id: Int)
given Showable[Student] with
def show(s: Student): String = s match
case Student(n, i) => "stud(" + n.show() + ", " + i.show() + ")"
@main def tryShowable =
println(10.show()) // "10"
println("hello!".show()) // "hello!"
println(Student("mario", 201).show()) // "stud(mario, 201)"
Notice that 10.show() looks like we dynamically extended Int
with a new method. This is the power of type classes combined with extension methods:
you can add behaviour to existing types without modifying them.
Scala’s type system supports higher-kinded types — types that abstract over type constructors (i.e., types that themselves take type parameters). This is essential for generalising over container-like types.
Types have kinds, which describe how many type parameters they take:
Int, String, Euro — fully applied typesShowable[T], Sequence[A] — one type parameterMonad[M[_]], Filterable[T[_]] — accepts a 1-kinded type// 0-kinded
trait FactorialModuleType:
def factorial(n: Int): Int
// 1-kinded
trait Showable[T]:
def show(t: T): String
// 2-kinded
trait Filterable[T[_]]:
def filter[A](t: T[A])(f: A => Boolean): T[A]
object FilterableOptional extends Filterable[Optional]:
def filter[A](t: Optional[A])(f: A => Boolean): Optional[A] = t match
case Just(a) if f(a) => Just(a)
case _ => None()
Higher-kinded types are what make the Monad type class possible: a monad
works for any type constructor F[_] that satisfies the monadic laws.
A monad is a design pattern that packages up a computation with some effect (optionality, multiple results, I/O, state) and provides two operations:
unit[A](a: => A): F[A] — wraps a value into the monadic contextflatMap[A, B](ma: F[A])(f: A => F[B]): F[B] — sequences computationsIn Scala, flatMap enables for-comprehension syntax:
// For-comprehension desugars into flatMap/map
val sum: Option[Double] =
for
x <- getRandom()
y <- getRandom()
z <- getRandom()
yield x + y + z
// Equivalent desugared form:
val sum2 = getRandom().flatMap(x =>
getRandom().flatMap(y =>
getRandom().map(z => x + y + z)))
The general Monad type class captures this pattern generically:
With the type class in place, we can derive generic operations like map2
(combine two monadic values) and seqN (sequence a stream of monadic values):
object Monad:
def map2[M[_]: Monad, A, B, C](m: M[A], m2: => M[B])(f: (A, B) => C): M[C] =
m.flatMap(a => m2.map(b => f(a, b)))
def seq[M[_]: Monad, A, B](m: M[A], m2: => M[B]): M[B] =
map2(m, m2)((a, b) => b)
def seqN[M[_]: Monad, A](stream: Stream[M[A]]): M[A] = stream match
case Cons(h, t) => (h(), t()) match
case (m, Empty()) => m
case (m, s) => seq(m, seqN(s))
Three laws define what it means to be a monad. Every Monad[F] implementation
must satisfy these for the abstraction to be well-behaved:
| Law | Expression | Meaning |
|---|---|---|
| Left identity | flatMap(unit(a), f) === f(a) |
Wrapping then flatMapping is the same as applying f directly |
| Right identity | flatMap(m, unit) === m |
FlatMapping with unit leaves the monad unchanged |
| Associativity | flatMap(flatMap(m, f), g) === flatMap(m, x => flatMap(f(x), g)) |
Sequencing of operations is associative; the grouping does not matter |
These laws correspond to the category-theoretic monad laws. Left/right identity says
that unit is a neutral element; associativity says that flatMap
behaves like composition. You should be able to verify these for a concrete monad
(e.g., manually verify for Optional).
The Optional monad models computations that may fail to produce a value.
unit wraps a value in Just; flatMap unwraps only
if the optional is Just, otherwise propagates Empty:
enum Optional[A]:
case Just(a: A)
case Empty()
given Monad[Optional] with
import Optional.{Just, Empty}
def unit[A](a: A): Optional[A] = Just(a)
extension [A](m: Optional[A])
def flatMap[B](f: A => Optional[B]): Optional[B] = m match
case Just(a) => f(a)
case Empty() => Empty()
// Usage
def optionalRandom(): Optional[Double] =
Just(java.lang.Math.random()).filter(_ < 0.9)
val m: Optional[Double] = for
x <- optionalRandom()
y <- optionalRandom()
z <- optionalRandom()
yield x + y + z
The Sequence monad models non-deterministic / multi-valued computations.
flatMap applies the function to every element and concatenates the results,
producing the cartesian product:
enum Sequence[A]:
case Cons(h: A, t: Sequence[A])
case Nil()
given Monad[Sequence] with
import Sequence.*
def unit[A](a: A): Sequence[A] = Cons(a, Nil())
extension [A](m: Sequence[A])
def flatMap[B](f: A => Sequence[B]): Sequence[B] = m match
case Cons(h, t) => f(h).append(t.flatMap(f))
case Nil() => Nil()
// Usage: all combinations
val s: Sequence[(Int, String)] = for
x <- Cons(10, Cons(20, Nil()))
y <- Cons("a", Cons("b", Nil()))
z <- Cons(true, Cons(false, Nil()))
yield if z then (x, y) else (0, y)
// Result: 8 elements — every combination of (10|20) × (a|b) × (true|false)
In a purely functional language, I/O operations (reading input, printing to console) are
side effects that violate referential transparency. The IO monad encapsulates
these effects as first-class values: an IO[A] is a description of a computation
that, when executed, performs I/O and produces an A.
case class IO[A](exec: () => A)
object IO:
def read(): IO[String] = IO(() => scala.io.StdIn.readLine)
def write[A](a: A): IO[A] = IO(() => { println(a); a })
def compute[A](a: => A): IO[A] = IO(() => a)
given Monad[IO] with
def unit[A](a: A): IO[A] = IO(() => a)
extension [A](m: IO[A])
def flatMap[B](f: A => IO[B]): IO[B] =
m match
case IO(e) => f(e())
Now we can write an interactive program purely functionally, composing I/O actions with for-comprehension:
val simpleInteractiveComputation: IO[Int] = for
s <- read()
_ <- write("Thanks for providing: " + s)
i <- compute(s.toInt)
j <- compute(i * 2)
_ <- write("Result is: " + j)
yield j
A complete functional game with stateful logic, all inside the IO monad:
enum Result: case Won, Lost
def drawNumberGame(attempts: Int, draw: Int): IO[Result] =
if attempts == 0 then compute(Result.Lost)
else for
_ <- write("Give your number: ")
d <- read()
i <- compute(d.toInt)
res <- if i == draw then
for _ <- write("Won!"); r <- compute(Result.Won) yield r
else for
_ <- write(if i > draw then "Too high!" else "Too low!")
r <- drawNumberGame(attempts - 1, draw)
yield r
yield res
// Start the game
drawNumberGame(10, scala.util.Random.nextInt(100))
The State monad models computations that carry evolving state through a
sequence of steps, all in a purely functional way. A State[S, A] is a function
from an initial state S to a pair (S, A) of the new state and the
result.
case class State[S, A](run: S => (S, A))
// Type lambda required for the given instance
given stateMonad[S]: Monad[[A] =>> State[S, A]] with
def unit[A](a: A): State[S, A] = State(s => (s, a))
extension [A](m: State[S, A])
override def flatMap[B](f: A => State[S, B]): State[S, B] =
State(s => m.run(s) match
case (s2, a) => f(a).run(s2))
The type lambda [A] =>> State[S, A] is needed because Monad
expects a 1-kinded type constructor, but State[S, A] has two type parameters.
The lambda “bakes in” a fixed S, yielding a 1-kinded type.
The capstone example combines everything: opaque types for the window and
counter states, module types for the WindowState and
CounterState abstractions, and the State monad to wire a
complete GUI application — all in pure FP.
trait WindowState:
type Window
def initialWindow: Window
def setSize(width: Int, height: Int): State[Window, Unit]
def addButton(text: String, name: String): State[Window, Unit]
def addLabel(text: String, name: String): State[Window, Unit]
def toLabel(text: String, name: String): State[Window, Unit]
def show(): State[Window, Unit]
def exec(cmd: => Unit): State[Window, Unit]
def eventStream(): State[Window, Stream[String]]
trait CounterState:
type Counter
def initialCounter(): Counter
def inc(): State[Counter, Unit]
def dec(): State[Counter, Unit]
def reset(): State[Counter, Unit]
def get(): State[Counter, Int]
def nop(): State[Counter, Unit]
object CounterStateImpl extends CounterState:
opaque type Counter = Int
def initialCounter(): Counter = 0
def inc(): State[Counter, Unit] = State(i => (i + 1, ()))
def dec(): State[Counter, Unit] = State(i => (i - 1, ()))
def reset(): State[Counter, Unit] = State(i => (0, ()))
def get(): State[Counter, Int] = State(i => (i, i))
def nop(): State[Counter, Unit] = State(i => (i, ()))
The mv combinator runs a model-state computation and a view-state computation
independently over a pair of states (SM, SV):
def mv[SM, SV, AM, AV](
m1: State[SM, AM],
f: AM => State[SV, AV]
): State[(SM, SV), AV] =
State: (sm, sv) =>
val (sm2, am) = m1.run(sm)
val (sv2, av) = f(am).run(sv)
((sm2, sv2), av)
The main controller creates the window, sets up buttons, then processes events infinitely:
@main def runMVC =
def windowCreation(str: String): State[Window, Stream[String]] = for
_ <- setSize(300, 300)
_ <- addButton(text = "inc", name = "IncButton")
_ <- addButton(text = "dec", name = "DecButton")
_ <- addButton(text = "reset", name = "ResetButton")
_ <- addButton(text = "quit", name = "QuitButton")
_ <- addLabel(text = str, name = "Label1")
_ <- show()
events <- eventStream()
yield events
val controller = for
events <- mv(seq(reset(), get()), i => windowCreation(i.toString()))
_ <- seqN(events.map(_ match
case "IncButton" => mv(seq(inc(), get()), i => toLabel(i.toString, "Label1"))
case "DecButton" => mv(seq(dec(), get()), i => toLabel(i.toString, "Label1"))
case "ResetButton" => mv(seq(reset(), get()), i => toLabel(i.toString, "Label1"))
case "QuitButton" => mv(nop(), _ => exec(sys.exit()))))
yield ()
controller.run((initialCounter(), initialWindow))
This is a complete application with no mutable variables whatsoever. The entire GUI state
— both the counter and the window — flows through State computations.
Be ready to explain how mv composes independent state threads, and how
seqN loops over the infinite event stream.
The lesson shows that pure FP is not an academic exercise: it can build real, interactive applications. Libraries like Cats provide these abstractions in a battle-tested form, used in production systems world-wide.
A regular type T = Int is just a synonym — clients can use T
and Int interchangeably. An opaque type T = Int creates a distinct type
that, outside the defining object, is not interchangeable with Int. This
enforces abstraction: all construction and manipulation must go through the ADT’s public API.
A module type (trait) defines an interface. You can add new implementations (new objects extending the trait) without modifying any code that depends on the interface. Functions that accept the module type as a parameter can be used with any implementation — this is the OCP applied at the module level without OOP inheritance.
given/using in Scala 3 and implicit in Scala 2.Conceptually they serve the same purpose, but Scala 3 separates the two use cases of
implicit: given for defining canonical terms, using
for declaring contextual parameters. This eliminates the ambiguity that plagued Scala 2
implicits (was this implicit a conversion, a parameter, or a class?) and makes the intent
explicit. The summon method replaces implicitly.
A context bound [T: TypeClass] is syntactic sugar for a using
parameter of type TypeClass[T]. It expresses ad-hoc polymorphism: the function
works for any T that has a TypeClass[T] witness in scope.
Example: def max[T: Ordered](seq: Sequence[T]): T = ... where
Ordered[T] provides the comparison logic.
A higher-kinded type abstracts over type constructors. For example, Monad[F[_]]
takes a 1-kinded type constructor F (like Optional,
Sequence, or IO) as a parameter. This allows us to write generic
monadic operations once, then instantiate them for any specific monad. Without higher-kinded
types, we would need a separate MonadOptional, MonadSequence, etc.
Left identity: flatMap(unit(a), f) === f(a) — ensures
unit does not introduce extra effects. Right identity:
flatMap(m, unit) === m — ensures unit does not discard effects.
Associativity: flatMap(flatMap(m, f), g) === flatMap(m, x => flatMap(f(x), g))
— ensures that sequencing is straightforward and refactorable. Together they let us reason
about monadic code equationally.
Instead of mutating a variable in place, the State monad threads state through a
computation as an explicit parameter and return value. A State[S, A] is a
function S => (S, A) that takes an incoming state and returns both a new
state and a result. flatMap composes these functions, threading the state
automatically. The run method executes the entire computation with an initial
state, never mutating anything.
mv combinator do in the MVC example?mv (model-view) runs two independent state computations over a pair of
states (SM, SV). The model computation m1: State[SM, AM] produces
a value AM, which is then fed into a view computation f: AM => State[SV, AV].
The states evolve independently, but the computations are sequenced together through the pair.
This lets us keep counter state and window state completely separate while composing them.