Free Handbook · Every example compiled & verified

Classes, Objects & Traits

Classes and constructors, private members, singleton and companion objects, traits and mixins, inheritance with override, enums and opaque types.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 04 · what you'll be able to do

  • Write a class with a primary constructor and decide which parameters become val or var fields
  • Hide state behind private members and create instances through a companion object's apply
  • Use singleton objects for utilities and shared state, and traits for abstract and concrete behaviour
  • Mix several traits into one class and predict the order super calls run in (linearization)
  • Extend classes with override, choose between an abstract class and a trait, and model fixed choices with enums and opaque types
01

Classes and constructors

A class is a blueprint for objects that bundle data with the methods that work on it. In Scala the constructor is not a separate method: the parameters go straight after the class name — class Account(owner: String, balance: Double) — and that line is the primary constructor. Every statement in the class body runs when an object is created. You create an instance by calling the class like a function, Account("Asha", 100); the new keyword still works but Scala 3 no longer needs it.

scalaMain.scala
class Account(val owner: String, var balance: Double):
  println(s"opening account for $owner")

  def deposit(amount: Double): Unit =
    balance += amount

  def summary: String = f"$owner%s has $balance%.2f"

@main def run(): Unit =
  val a = Account("Asha", 100)
  a.deposit(25.5)
  println(a.summary)
  println(a.owner)
  a.balance = 0
  println(a.balance)
Outputcompiled & run with real Scala
opening account for Asha
Asha has 125.50
Asha
0.0

The println in the class body ran during construction. summary has no parentheses: a method that only reads state is conventionally declared and called without (), so it looks like a field.

Your turn

Add a method withdraw(amount: Double): Boolean that only takes money out when the balance is high enough and returns whether it did.

val, var or nothing: what the parameter becomes

ParameterReadable from outside?Reassignable?Use it for
class A(val x: Int)Yes — a public getterNoData callers should see
class A(var x: Int)Yes — getter and setterYesRarely; prefer methods that change state deliberately
class A(x: Int)No — private to the classNoInputs used only inside the class
Error you will hit

Constructor parameter without val is not a field

scala
class User(name: String, age: Int)

@main def run(): Unit =
  val u = User("Asha", 30)
  println(u.name)
-- [E173] Reference Error: Main.scala:5:12
5 |  println(u.name)
  |          ^^^^^^
  |value name cannot be accessed as a member of (u : User) from the top-level definitions in package <empty>.
  |  private value name can only be accessed from class User.
1 error found
Compilation failed
Why the compiler said that

A plain constructor parameter is visible everywhere inside the class, but Scala treats it as private. Nothing outside User can read it, so u.name is refused. Java and Kotlin developers hit this constantly.

The fix

Put val in front of each parameter that should be public. (A case class, Module 05, makes every parameter a val for you.)

scala
class User(val name: String, val age: Int)

@main def run(): Unit =
  val u = User("Asha", 30)
  println(u.name)

Default arguments from Module 03 work on constructors too, which is why Scala rarely needs extra constructors. When you do, an auxiliary constructor is written def this(...) and must start by calling another constructor of the same class.

scalaMain.scala
class Server(val host: String, val port: Int = 8080):
  def this(port: Int) = this("localhost", port)
  override def toString: String = s"$host:$port"

@main def run(): Unit =
  println(Server("api.local"))
  println(Server("api.local", 9000))
  println(Server(3000))

  val a = Server("x", 1)
  val b = Server("x", 1)
  println(a == b)
Outputcompiled & run with real Scala
api.local:8080
api.local:9000
localhost:3000
false

Overriding toString controls what println shows. The last line surprises everyone once: two plain class instances with the same fields are not equal, because == on a regular class compares identity. Case classes (Module 05) change that.

02

Private and protected members

Everything in a Scala class is public by default — there is no public keyword. Mark a member private to hide it from everything outside the class (the companion object is the one exception, next lesson), or protected to share it with subclasses only. Keeping mutable state private and exposing methods that change it deliberately is called encapsulation: the class can then guarantee its own rules, such as "a counter never goes below zero".

scalaMain.scala
class Counter(start: Int = 0):
  private var count = start

  def increment(): Unit = count += 1
  def decrement(): Unit = if count > 0 then count -= 1
  def current: Int = count

@main def run(): Unit =
  val c = Counter()
  c.increment()
  c.increment()
  c.decrement()
  c.decrement()
  c.decrement()
  println(c.current)
Outputcompiled & run with real Scala
0

Three decrements after two increments still leaves 0: the only way to change count is through methods that enforce the rule. decrement can use if without else because its result type is Unit.

Your turn

Add a private max constructor parameter and make increment stop there.

Error you will hit

Assigning a private var from outside the class

scala
class Counter:
  private var count = 0
  def increment(): Unit = count += 1
  def current: Int = count

@main def run(): Unit =
  val c = Counter()
  c.increment()
  c.count = 10
  println(c.current)
-- [E173] Reference Error: Main.scala:9:4
9 |  c.count = 10
  |  ^^^^^^^
  |variable count cannot be accessed as a member of (c : Counter) from the top-level definitions in package <empty>.
  |  private variable count can only be accessed from class Counter.
1 error found
Compilation failed
Why the compiler said that

count is private, so code outside Counter — here the top-level @main method — cannot read or write it. This is the compiler protecting the class's rules, not getting in your way.

The fix

Change state through a public method the class provides (and add one if the operation is legitimate, such as reset()).

scala
class Counter:
  private var count = 0
  def increment(): Unit = count += 1
  def reset(): Unit = count = 0
  def current: Int = count

@main def run(): Unit =
  val c = Counter()
  c.increment()
  c.reset()
  println(c.current)
Scoped access
Scala can also widen private to an enclosing package or object: private[billing] def rate is visible anywhere inside package billing and nowhere else. Libraries use it to share internals between their own files without making them public API.
03

Singleton objects and companion objects

An object defines a class and its only instance in one go. It is created the first time it is used, and there is exactly one. Scala has no static keyword; objects fill that role — utility methods, constants, shared counters, and the program entry point in older code (object Main { def main(args: Array[String]) }).

scalaMain.scala
object MathUtils:
  println("MathUtils initialised")
  val Tau = 6.28318
  def clamp(x: Int, lo: Int, hi: Int): Int = x.max(lo).min(hi)

@main def run(): Unit =
  println("start")
  println(MathUtils.clamp(15, 0, 10))
  println(MathUtils.clamp(-3, 0, 10))
  println(MathUtils.Tau)
Outputcompiled & run with real Scala
start
MathUtils initialised
10
0
6.28318

"start" prints first: the object's body runs lazily, on first access, and only once.

An object with the same name as a class, in the same file, is that class's companion object. The two can read each other's private members. The companion is where factory methods live, and one method name is special: apply. Writing Temperature(21) calls Temperature.apply(21), so a companion apply lets you validate or convert input before the object is built. Make the real constructor private and the factory becomes the only way in.

scalaMain.scala
class Temperature private (val celsius: Double):
  override def toString: String = s"$celsius C"

object Temperature:
  private var created = 0

  def apply(celsius: Double): Temperature =
    created += 1
    new Temperature(celsius.max(-273.15))

  def fromFahrenheit(f: Double): Temperature = apply((f - 32) * 5 / 9)

  def count: Int = created

@main def run(): Unit =
  println(Temperature(21))
  println(Temperature(-500))
  println(Temperature.fromFahrenheit(212))
  println(Temperature.count)
Outputcompiled & run with real Scala
21.0 C
-273.15 C
100.0 C
3

Inside the companion, new Temperature(...) is allowed even though the constructor is private. Outside, new Temperature(5) would not compile — every temperature goes through the clamp in apply.

Your turn

Add def freezing: Temperature = apply(0) to the companion and print it.

In real jobs
Companion objects are everywhere in Scala code: List(1, 2, 3) is List.apply, Some(5) is Some.apply, and JSON codecs, configuration defaults and type-class instances (Module 09) are conventionally placed in the companion so the compiler finds them automatically.
04

Traits: abstract and concrete members

A trait describes a capability that many classes can share. It can declare abstract members — a def or val with no body, which every concrete class must supply — and concrete members with a body, which classes inherit for free. A class extends one trait and adds more with commas (or with). Like a Java interface with default methods, but traits can also hold fields and constructor parameters.

scalaMain.scala
trait Shape:
  def name: String
  def area: Double
  def describe: String = f"$name with area $area%.1f"

class Circle(r: Double) extends Shape:
  val name = "circle"
  def area: Double = math.Pi * r * r

class Rect(w: Double, h: Double) extends Shape:
  val name = "rectangle"
  def area: Double = w * h

@main def run(): Unit =
  val shapes: List[Shape] = List(Circle(1), Rect(2, 3))
  for s <- shapes do println(s.describe)
  println(shapes.map(_.area).sum.round)
Outputcompiled & run with real Scala
circle with area 3.1
rectangle with area 6.0
9

describe is written once in the trait and works for every shape, because it calls the abstract name and area that each class fills in. A val is allowed to implement an abstract def. Implementing an abstract member does not need override.

Your turn

Add a Square(side: Double) class and put one in the list.

Error you will hit

A class that forgets an abstract member

scala
trait Shape:
  def area: Double
  def name: String

class Square(side: Double) extends Shape:
  def area: Double = side * side

@main def run(): Unit =
  println(Square(3).area)
-- [E231] Declaration Error: Main.scala:5:6
5 |class Square(side: Double) extends Shape:
  |      ^^^^^^
  |class Square needs to be abstract, since def name: String in trait Shape is not defined
1 error found
Compilation failed
Why the compiler said that

A concrete class must provide a body for every abstract member it inherits. Square implemented area but not name, so objects of it would have a hole. The compiler suggests the only other legal option — declaring Square abstract — but that is rarely what you want.

The fix

Implement the missing member.

scala
trait Shape:
  def area: Double
  def name: String

class Square(side: Double) extends Shape:
  def area: Double = side * side
  def name: String = "square"

@main def run(): Unit =
  println(Square(3).area)

Traits can take parameters in Scala 3, and a class can mix in several of them. Each trait adds one slice of behaviour, and the class combines them.

scalaMain.scala
trait Named(val name: String)

trait Greeter:
  def greet(who: String): String = s"Hello, $who"

trait Loud:
  def shout(s: String): String = s.toUpperCase + "!"

class Bot extends Named("helper"), Greeter, Loud

@main def run(): Unit =
  val b = Bot()
  println(b.name)
  println(b.shout(b.greet("Ravi")))
Outputcompiled & run with real Scala
helper
HELLO, RAVI!
05

Mixin composition and linearization

When several traits override the same method and each calls super, which one runs first? Scala answers with linearization: it lines the class and all its traits up in a single order and super means "the next one in that line", not "my parent". The rule of thumb: read the extends clause right to left. The rightmost trait runs first, and each super.method hands over to the trait on its left, down to the base. That lets you stack small behaviours like decorators.

scalaMain.scala
trait Formatter:
  def format(msg: String): String = msg

trait Prefixed extends Formatter:
  override def format(msg: String): String = super.format(s"note: $msg")

trait Upper extends Formatter:
  override def format(msg: String): String = super.format(msg.toUpperCase)

class A extends Formatter, Prefixed, Upper
class B extends Formatter, Upper, Prefixed

@main def run(): Unit =
  println(A().format("disk full"))
  println(B().format("disk full"))
Outputcompiled & run with real Scala
note: DISK FULL
NOTE: DISK FULL

Same traits, different order, different result. In A, Upper is rightmost, so the text is upper-cased first and the prefix added after. In B, the prefix is added first and then everything is upper-cased.

Your turn

Add a trait Trimmed that calls super.format(msg.trim) and mix it in last, then call format(" hi ").

VisualizeA().format("disk full") — following superStep 1 / 6
trait Formatter:
def format(msg: String): String = msg
trait Prefixed extends Formatter:
override def format(msg: String): String = super.format(s"note: $msg")
trait Upper extends Formatter:
override def format(msg: String): String = super.format(msg.toUpperCase)
class A extends Formatter, Prefixed, Upper
@main def run(): Unit =
println(A().format("disk full"))
Line 10

The compiler linearizes A as A, Upper, Prefixed, Formatter — the rightmost trait comes right after the class itself.

Variables now

nothing yet

All 6 steps as a table
StepLineWhat happenedVariables now
110The compiler linearizes A as A, Upper, Prefixed, Formatter — the rightmost trait comes right after the class itself.
213format is called on an A. A does not define it, so the first in line that does is Upper.msg = "disk full"
38Upper.format upper-cases the text and calls super.format. In this linearization, super of Upper is Prefixed — not Formatter, even though Upper extends Formatter.msg = "DISK FULL"
45Prefixed.format adds the prefix and calls super.format, which is now Formatter.msg = "note: DISK FULL"
52Formatter.format returns the text unchanged. Each call returns in turn.msg = "note: DISK FULL"
613The result is printed.
Why this solves the diamond problem
Both Prefixed and Upper extend Formatter, the classic "diamond". Languages with multiple class inheritance must decide which parent wins. Scala does not have to: linearization includes Formatter exactly once, at the end, so every super chain is a straight line.
06

Extending classes, override and abstract classes

A class can extend one other class (plus any number of traits) and pass arguments to its constructor: class Dog(name: String) extends Animal(name). To replace a method that already has a body you must write override — the compiler refuses to let you override by accident, and also complains if you write override on something that overrides nothing (a typo in the name). super.method calls the parent's version. Mark a class or method final to forbid further overriding.

scalaMain.scala
class Animal(val name: String):
  def sound: String = "..."
  def speak: String = s"$name says $sound"

class Dog(name: String) extends Animal(name):
  override def sound: String = "Woof"

class Puppy(name: String) extends Dog(name):
  override def sound: String = super.sound + " (squeaky)"

@main def run(): Unit =
  val pets: List[Animal] = List(Animal("Generic"), Dog("Rex"), Puppy("Bit"))
  pets.foreach(p => println(p.speak))
Outputcompiled & run with real Scala
Generic says ...
Rex says Woof
Bit says Woof (squeaky)

speak is defined once in Animal, yet it calls the overriding sound of whatever object it runs on. That is dynamic dispatch: the list is typed List[Animal], but each element keeps its real class.

Error you will hit

Overriding a concrete method without override

scala
class Animal:
  def sound: String = "..."

class Dog extends Animal:
  def sound: String = "Woof"

@main def run(): Unit =
  println(Dog().sound)
-- [E164] Declaration Error: Main.scala:5:6
5 |  def sound: String = "Woof"
  |      ^
  |      error overriding method sound in class Animal of type => String;
  |        method sound of type => String needs `override` modifier
1 error found
Compilation failed
Why the compiler said that

Animal.sound already has a body, so defining sound again in Dog replaces it. Scala makes you say so explicitly, so nobody replaces a parent method by picking the same name by chance.

The fix

Add override. (Implementing an abstract member, as in the traits lesson, does not need it.)

scala
class Animal:
  def sound: String = "..."

class Dog extends Animal:
  override def sound: String = "Woof"

@main def run(): Unit =
  println(Dog().sound)

An abstract class is a class that may have abstract members and therefore cannot be instantiated directly — only its subclasses can. Traits can do almost everything abstract classes can, so the choice is mostly practical.

scalaMain.scala
abstract class Employee(val name: String, val baseSalary: Int):
  def bonus: Int
  def total: Int = baseSalary + bonus

class Engineer(name: String) extends Employee(name, 5000):
  def bonus: Int = 800

class Manager(name: String, reports: Int) extends Employee(name, 6000):
  def bonus: Int = reports * 300

@main def run(): Unit =
  val staff = List(Engineer("Asha"), Manager("Ravi", 4))
  for e <- staff do println(s"${e.name}: ${e.total}")
Outputcompiled & run with real Scala
Asha: 5800
Ravi: 7200
Error you will hit

Instantiating an abstract class

scala
abstract class Shape:
  def area: Double

@main def run(): Unit =
  val s = new Shape
  println(s.area)
-- [E042] Type Error: Main.scala:5:14
5 |  val s = new Shape
  |              ^^^^^
  |              Shape is abstract; it cannot be instantiated
1 error found
Compilation failed
Why the compiler said that

Shape has an abstract area. An object of it would have no answer for s.area, so the compiler refuses to create one.

The fix

Create a concrete subclass — or, for a one-off, an anonymous class that fills in the missing members on the spot.

scala
abstract class Shape:
  def area: Double

@main def run(): Unit =
  val s = new Shape:
    def area: Double = 2.0
  println(s.area)

Trait

  • A class can mix in many
  • Stackable with super (linearization)
  • Can take parameters in Scala 3
  • The default choice for a shared capability or interface

Abstract class

  • A class can extend only one
  • Natural when there is a single clear "is-a" base with shared state
  • Callable from Java as an ordinary base class
  • Use it when Java interop or one fixed base type matters
07

Enums

When a value can only be one of a fixed set — a traffic light colour, an order status, a log level — use an enum. Scala 3 enums are declared with case entries and come with useful methods built in: values lists them all in order, ordinal gives each one's position, and valueOf turns a name back into a case. A match on an enum is checked for completeness: forget a case and the compiler warns you.

scalaMain.scala
enum Light:
  case Red, Amber, Green

  def next: Light = this match
    case Red   => Green
    case Green => Amber
    case Amber => Red

@main def run(): Unit =
  println(Light.values.toList)
  println(Light.Amber.ordinal)
  println(Light.valueOf("Green"))

  var l = Light.Red
  for _ <- 1 to 4 do
    print(s"$l ")
    l = l.next
  println()
Outputcompiled & run with real Scala
List(Red, Amber, Green)
1
Green
Red Green Amber Red 

Enums can have methods like any class. Inside the enum, the cases are in scope, so next can say Red rather than Light.Red. valueOf on a name that does not exist throws IllegalArgumentException.

Your turn

Add a method canGo: Boolean that is true only for Green.

Enums can carry data too
Enum cases can take parameters — a Planet with its mass, or a Shape whose Circle case holds a radius — which makes enums Scala 3's short way to write an algebraic data type. That is covered with pattern matching in Module 05 — Enums with parameters.
08

Opaque types

A function transfer(from: Long, to: Long, amount: Long) will happily accept its arguments in the wrong order. An opaque type gives an existing type a new name that the compiler treats as a different type everywhere except where it is defined. At runtime it is still the plain Long — no wrapper object, no cost — but at compile time a UserId and an OrderId can no longer be mixed up.

scalaMain.scala
object Ids:
  opaque type UserId = Long

  object UserId:
    def apply(n: Long): UserId = n

  extension (id: UserId) def value: Long = id

import Ids.UserId

def findUser(id: UserId): String = s"user #${id.value}"

@main def run(): Unit =
  val id = UserId(42)
  println(findUser(id))
  println(id.value + 1)
Outputcompiled & run with real Scala
user #42
43

Inside Ids the compiler knows UserId is a Long, so apply and value can convert freely. Outside, the only ways in and out are the ones Ids chose to offer. extension adds a method to an existing type; Module 09 covers extension methods in full.

Error you will hit

Passing the underlying type where an opaque type is expected

scala
object Ids:
  opaque type UserId = Long
  object UserId:
    def apply(n: Long): UserId = n

import Ids.UserId

def findUser(id: UserId): String = s"user $id"

@main def run(): Unit =
  println(findUser(42L))
-- [E007] Type Mismatch Error: Main.scala:11:19
11 |  println(findUser(42L))
   |                   ^^^
   |                   Found:    (42L : Long)
   |                   Required: Ids.UserId
1 error found
Compilation failed
Why the compiler said that

That is the whole point of the opaque type: outside Ids, a raw Long is not a UserId. Any number could be an order count or a timestamp; only values created through UserId(...) count as user ids.

The fix

Build the value through the constructor the defining object provides.

scala
object Ids:
  opaque type UserId = Long
  object UserId:
    def apply(n: Long): UserId = n

import Ids.UserId

def findUser(id: UserId): String = s"user $id"

@main def run(): Unit =
  println(findUser(UserId(42L)))
Primary constructor
The parameter list after the class name; together with the class body it is the code that runs when an object is created.
Auxiliary constructor
An extra constructor written def this(...) that must first call another constructor.
Field
A val or var that belongs to an object. A constructor parameter becomes a public field only with val or var.
Encapsulation
Hiding state behind private members so the class controls how it changes.
Singleton object
An object declaration: a class with exactly one instance, created lazily on first use.
Companion object
An object with the same name as a class in the same file; the two can access each other's private members.
apply
A method called when an object is used like a function: Temperature(21) means Temperature.apply(21).
Trait
A reusable set of abstract and concrete members that classes can mix in; a class can extend many.
Linearization
The single order Scala puts a class and its traits in; super calls follow it, rightmost trait first.
override
The modifier required to replace an inherited member that already has a body.
Abstract class
A class that may declare abstract members and cannot be instantiated directly.
Enum
A type with a fixed set of named cases, with values, ordinal and valueOf built in.
Opaque type
A new type name for an existing type that is distinct at compile time but costs nothing at runtime.
Quick check

Given class C extends Base, T1, T2 where T1 and T2 both override m and call super.m, whose m runs first when you call C().m?

Quick check

With class Point(x: Int, val y: Int) and val p = Point(1, 2), which line compiles?

Frequently asked questions

What is the difference between a class and an object in Scala?
A class is a blueprint you can create many instances of. An object is a single instance, created lazily the first time it is used. Scala has no static members; you put what would be static in Java into an object — often the companion object of a class, which shares its name and can access its private members.
Should I use a trait or an abstract class in Scala 3?
Default to a trait: a class can mix in many, traits stack through linearization, and in Scala 3 they can take parameters. Choose an abstract class when you need a single base class that Java code will extend, or when a type really has one fixed base with shared constructor logic.
Why do I not need new in Scala 3?
Scala 3 generates a constructor proxy for every class, so Account("Asha", 100) works like new Account("Asha", 100). You still need new for an anonymous class (new Shape: ...), and inside a companion apply that builds the object — there, Temperature(x) would call apply again instead of the constructor.

Finish the Scala handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.