Inheritance: Abstract, Sealed, Open & Final

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, 2026-10-12) and every example was run with Scala CLI and Scala 3.10.0 on JDK 26 on 2026-10-15 (the examples were not re-run on 3.9 or 3.3). open and the feature warning for ad-hoc extension were added in Scala 3.0, so everything here is available on the 3.9 LTS and 3.3 LTS lines as well; only the wording of some diagnostics depends on the compiler version. A Scala class decides how it may be extended with a small set of modifiers: abstract (cannot be instantiated, may leave members undefined), final (cannot be extended), sealed (may be extended only in its own file), open (explicitly designed to be extended), and a default of "closed in spirit": a plain class that can be extended but only with a warning from another file. This page covers those modifiers and the rules for override. Traits and mixin composition are in Traits & Mixins, the sum types built with sealed are in Algebraic Data Types & GADTs, and the visibility modifiers (private, protected) in Annotations & Visibility.

The examples use the trailhead scenario of this section: a toolkit that plans hikes on a network of mountain trails.

Abstract classes

An abstract class cannot be instantiated and may declare members without a body. Subclasses supply the missing parts. A member with no body is abstract; the abstract keyword is required on the class but not on the members, unlike Java.

//> using scala 3.10.0

abstract class Trailhead(val name: String, val elevationM: Int):
  def difficulty: String                       // abstract member
  def label: String = s"$name ($elevationM m, $difficulty)"   // concrete member
  override def toString: String = label

class Meadow(name: String) extends Trailhead(name, 1200):
  def difficulty = "easy"

class Summit(name: String, elevationM: Int) extends Trailhead(name, elevationM):
  def difficulty = if elevationM > 3000 then "hard" else "moderate"
  override def label: String = "^ " + super.label

@main def i1(): Unit =
  val all: List[Trailhead] = List(Meadow("Prado"), Summit("Aneto", 3404), Summit("Tobacor", 2779))
  all.foreach(println)
Prado (1200 m, easy)
^ Aneto (3404 m, hard)
^ Tobacor (2779 m, moderate)

What the example shows:

  • extends Trailhead(name, 1200) passes arguments to the parent constructor. A class has exactly one parent class, and its constructor arguments are given in the extends clause. Further parents must be traits.

  • A concrete subclass must define every abstract member. Meadow and Summit implement difficulty without override, because implementing an abstract member does not need the keyword (it is allowed, and some teams write it for clarity).

  • super.label calls the parent’s version, here from a method that replaces it. The rules for what super means once traits are mixed in are in Traits & Mixins.

  • An abstract member can be a def, a val or a type. An abstract val can be implemented by a val. A def can be implemented by a def, a val or a lazy val (see the initialisation-order section for why this matters).

  • An abstract class may have constructor parameters; a trait may as well since Scala 3 (trait parameters), so an abstract class is needed less often than in Java. Prefer a trait when no constructor argument or final initialisation is required, and an abstract class when it is, or when Java code needs to extend it as a class.

Missing members are caught at compile time, and an abstract class cannot be instantiated. These are the real messages:

//> using scala 3.10.0

abstract class Trailhead(val name: String):
  def difficulty: String

class Bad1 extends Trailhead("x")
[error] ./i2c.scala:6:7
[error] class Bad1 needs to be abstract, since def difficulty: String in class Trailhead is not defined
[error] class Bad1 extends Trailhead("x")
[error]       ^^^^
Error compiling project (Scala 3.10.0, JVM (26))
//> using scala 3.10.0

abstract class Trailhead(val name: String):
  def difficulty: String
  final def id: String = name.toLowerCase
  def label: String = name

class Bad1 extends Trailhead("x")

@main def i2(): Unit =
  val t = new Trailhead("q")
[error] ./i2.scala:11:15
[error] Trailhead is abstract; it cannot be instantiated
[error]   val t = new Trailhead("q")
[error]               ^^^^^^^^^
Error compiling project (Scala 3.10.0, JVM (26))

(The compiler stops after the first phase that reports errors, so the missing difficulty of Bad1 appears only once the instantiation error is fixed.) Without new, Trailhead("q") fails differently, with "Expected a term, but found a type": a creator application cannot create an abstract class (see Objects & Companions).

override and final

A member that replaces an inherited concrete member needs the override modifier; the compiler refuses silent replacement. A member marked final cannot be replaced at all, and a final class cannot be extended.

//> using scala 3.10.0

abstract class Trailhead(val name: String):
  def difficulty: String
  final def id: String = name.toLowerCase
  def label: String = name

class Bad2 extends Trailhead("y"):
  def difficulty = "easy"
  def label: String = "again"
  def id: String = "z"
  override def name = "n"
[error] ./i2b.scala:10:7
[error] error overriding method label in class Trailhead of type => String;
[error]   method label of type => String needs `override` modifier
[error]   def label: String = "again"
[error]       ^
[error] ./i2b.scala:11:7
[error] error overriding method id in class Trailhead of type => String;
[error]   method id of type => String cannot override final member method id in class Trailhead
[error]   def id: String = "z"
[error]       ^
[error] ./i2b.scala:12:16
[error] error overriding value name in class Trailhead of type String;
[error]   method name of type => String needs to be a stable, immutable value
[error]   override def name = "n"
[error]                ^
Error compiling project (Scala 3.10.0, JVM (26))

The three errors, in order:

  • A missing override. label is concrete in Trailhead, so replacing it needs override. The rule protects against accidental overrides when the parent later gains a method of the same name, and the keyword also makes the compiler reject an override that overrides nothing (a typo in the name).

  • A final member. id is final, so the subclass cannot replace it. Use final for members whose behaviour the class depends on, or that implement an invariant. A private member is never overridden, only hidden.

  • A val can be overridden only by a val. A def cannot replace a val, because a val is a stable value and a def is not; the other direction (a val replacing a def) is fine.

The rules in summary:

Modifier Effect

abstract (class)

Cannot be instantiated; may have undefined members.

override

Required to replace a concrete member; optional when implementing an abstract one.

final (member)

Cannot be overridden.

final (class)

Cannot be extended: class Cabin extends Hut fails with "class Cabin cannot extend final class Hut".

sealed

Direct subtypes may be defined only in the same file; enables exhaustivity checking.

open

The class is designed to be extended anywhere; extending it never warns.

protected

Visible in subclasses (and, unlike Java, not in the same package).

Java and Kotlin differ here: a Java method can be overridden unless final and a class can be extended unless final or sealed; Kotlin classes and members are final by default and need open. Scala sits in between: everything is open to extension unless final, but extension of an unmarked class from another file gets a warning, described below. See Inheritance and Polymorphism in Java.

sealed

A sealed class or trait can be extended only by classes and objects in the same source file. The compiler therefore knows all direct subtypes and can check a match for exhaustivity.

//> using scala 3.10.0

sealed trait Trail
final case class Loop(km: Double) extends Trail
final case class OneWay(km: Double) extends Trail
case object Closed extends Trail

def km(t: Trail): Double = t match
  case Loop(k)   => k
  case OneWay(k) => k

@main def i3(): Unit = println(km(Closed))
[warn] ./i3.scala:8:28
[warn] match may not be exhaustive.
[warn]
[warn] It would fail on pattern case: Closed
[warn] def km(t: Trail): Double = t match
[warn]                            ^
Exception in thread "main" scala.MatchError: Closed (of class Closed$)
	at i3$package$.km(i3.scala:10)
	at i3$package$.i3(i3.scala:12)
	at i3.main(i3.scala:12)

The warning is the benefit of sealed: the missing Closed case is reported at compile time, before the program throws a MatchError at run time. A new case added later makes every non-exhaustive match in the code base warn. A final modifier on the subclasses (as above) closes the hierarchy at the leaves. Attempting to extend a sealed type from another file is an error. With sealed trait Shape in one file and a case class in another:

[error] ./i4b.scala:1:12
[error] Cannot extend sealed trait Shape in a different source file
[error] case class Other(s: String) extends Shape
[error]            ^
Error compiling project (Scala 3.10.0, JVM (26))

Notes on sealed:

  • A sealed class can itself be abstract (sealed abstract class), in which case it cannot be instantiated; a sealed trait is already abstract.

  • Subclasses of a sealed type are not themselves sealed unless declared so. A subclass that is neither final, sealed nor case leaves a hole in exhaustivity reasoning, so make leaves final (or case, which Scala 3 does not make final).

  • sealed and open are mutually exclusive: sealed open class A fails with "illegal combination of modifiers: sealed and open for: class A".

  • Java 17 sealed classes with permits are the nearest Java equivalent (see Records and Sealed Classes in Java). Kotlin’s sealed class is the same idea (see Sealed Classes and Enums in Kotlin).

For the enum form of a sealed hierarchy, see Enums; case classes are in Case Classes.

open and ad-hoc extension

A class that is not final, not sealed and not abstract can be extended anywhere, but doing so from a different file than the parent is a feature warning in Scala 3 unless the parent is declared open. The message names the reason: the author of a plain class has not promised that its internal calling patterns will stay stable. The reference says the warning is produced "only under -source future" and "by default from Scala 3.4 on". Checked here on 3.10.0, it was still not produced by default: it appeared only with -feature and -source:future (or future-migration), and not with -feature alone at the default source level. Treat the reference sentence about 3.4 as not yet true of 3.10.0, and turn the warning on explicitly if you want it. The layouts below were compiled as files in package p with scala run . -O -feature -O -source:future.

// Hut.scala
package p
class Hut(val name: String):
  def describe: String = s"Hut $name"

// Cabin.scala
package p
class Cabin(n: String) extends Hut(n)
[warn] ./Cabin.scala:2:32
[warn] Unless class Hut is declared 'open', its extension in a separate file should be enabled
[warn] by adding the import clause 'import scala.language.adhocExtensions'
[warn] or by setting the compiler option -language:adhocExtensions.
[warn] See the Scala docs for value scala.language.adhocExtensions for a discussion
[warn] why the feature should be explicitly enabled.
[warn] class Cabin(n: String) extends Hut(n)
[warn]                                ^^^^^^
Hut A

There are two ways to silence it. The author of the parent marks it open, which says "extension is part of the contract", or the author of the child opts in with the language import:

// Hut.scala
package p
open class Hut(val name: String):
  def describe: String = s"Hut $name"

// or, in Cabin.scala, leaving Hut unchanged:
package p
import scala.language.adhocExtensions
class Cabin(n: String) extends Hut(n)

Both variants compiled with no warning (Hut A printed). The options have different meaning:

  • open class is a promise by the author of the parent: there is a documented extension contract describing which methods are hooks, which calls the class makes on itself and what a subclass may rely on.

  • import scala.language.adhocExtensions is a decision by the extender, who accepts that the parent’s author made no promise (typical for a test double or a temporary patch to a library class) and that a later release may break the extension.

  • open is a soft keyword: an ordinary identifier except in modifier position. An open class cannot be final or sealed, and a trait or an abstract class is always open, so the modifier is redundant on them.

  • open and sealed combine across parents. An open subclass of a sealed parent is allowed (checked: open class Pass extends Th under sealed abstract class Th, with Pass extended from another file, compiled), while extending Th itself from another file is the error "Cannot extend sealed class Th in a different source file".

  • Extension in the same file never warns, because the author of the parent controls that file. Using it, a class that is neither open nor abstract can still have a few subclasses next to it, which is how sealed hierarchies are written.

The reference puts the relationship this way: a class that is neither abstract nor open is similar to a sealed class, but extending it from another file is permitted with the adhocExtensions feature (and a warning without it), whereas for a sealed class it is an error. A sealed hierarchy can mix all the modifiers:

//> using scala 3.10.0

sealed abstract class Trailhead(val name: String)
final class Meadow(name: String) extends Trailhead(name)
sealed class Summit(name: String) extends Trailhead(name)
final class Aneto extends Summit("Aneto")
open class Pass(name: String) extends Trailhead(name)

@main def i8(): Unit =
  val t: Trailhead = Aneto()
  println(t match
    case _: Meadow => "meadow"
    case _: Summit => "summit"
    case _: Pass   => "pass")
summit

The hierarchy is shown below. Pass is open below a sealed parent: other files may extend Pass, while Meadow and Aneto end the chain and only this file may add direct subclasses of Trailhead or Summit.

classDiagram class Trailhead { <<sealed abstract>> +name: String } class Meadow { <<final>> } class Summit { <<sealed>> } class Aneto { <<final>> } class Pass { <<open>> } Trailhead <|-- Meadow Trailhead <|-- Summit Trailhead <|-- Pass Summit <|-- Aneto

Planning for extension

The Scala 3 Book recommends deciding on purpose how each class may be extended:

  • Meant to be extended: mark it open (or make it abstract or a trait), document which methods are hooks and which are called by which, and keep that contract stable.

  • Not meant to be extended: mark it final. This is the safest default for value-like classes, and case classes are better as final case class.

  • Closed set of alternatives: use sealed (or an enum), put every subtype in the file, and let the compiler check match expressions.

  • No decision: leave the modifier off. That is the "might be extended ad hoc by someone else, on their own risk" state that the feature warning pushes you to make explicit.

Two consequences follow. A method that a subclass overrides is part of the public contract of the class, so an open class should make helper methods private or final. And a public member of a trait or abstract class that is never meant to be changed should be final.

Initialization order and early initializers

A superclass constructor runs before the subclass body, so a parent that reads an abstract val sees its default value. This is the classic Scala initialization trap, and it is the same in Java for overridden methods:

//> using scala 3.10.0

abstract class Parent:
  val size: Int
  println(s"Parent init: size = $size")

class Child extends Parent:
  val size = 10
  println(s"Child init: size = $size")

abstract class Parent2:
  def size: Int
  println(s"Parent2 init")

class Child2 extends Parent2:
  lazy val size = 10

trait HasSize(val size: Int):
  println(s"HasSize init: size = $size")

class Child3 extends HasSize(10)

@main def i7(): Unit =
  Child()
  Child2()
  Child3()
Parent init: size = 0
Child init: size = 10
Parent2 init
HasSize init: size = 10
  • Parent printed size = 0, not 10: the parent ran first, so the child’s val was not yet assigned. Reading an abstract val from a parent constructor is almost always a bug.

  • Abstract def plus lazy val in the child (Child2) avoids it, because lazy val is computed on first use.

  • A trait or class parameter (HasSize(10)) is the Scala 3 answer: the argument is available while the parent initialises, with no ordering problem. See Traits & Mixins for trait parameters.

Scala 2 had early initializers (class Early extends { val x = 1 } with Base) for this, and Scala 3 dropped them. The compiler says what to do instead:

[error] ./i6.scala:7:21
[error] `extends` must be followed by at least one parent
[error] class Early extends { val x = 1 } with Base
[error]                     ^
[error] ./i6.scala:7:35
[error] Early definitions are not supported; use trait parameters instead
[error] class Early extends { val x = 1 } with Base
[error]                                   ^^^^
[error] ./i6.scala:7:40
[error] end of toplevel definition expected but identifier found
[error] class Early extends { val x = 1 } with Base
[error]                                        ^^^^
Error compiling project (Scala 3.10.0, JVM (26))

The Scala 3 compiler also has a safe-initialization check (-Wsafe-init) that finds such reads; it is described in Traits & Mixins and in the Scala 3 Reference: Safe Initialization.

Pitfalls

  • Forgetting override is an error, and so is a typo in an override name. override def hikee for a parent method hike fails with "method hikee overrides nothing"; that is the benefit of the keyword.

  • Reading an abstract val in a parent constructor sees 0/null/false. Use a trait or class parameter, a lazy val or a def.

  • A sealed hierarchy split across files is an error, and a package split of the same source is no help: all direct subtypes must live in the one file.

  • A non-final leaf class weakens exhaustivity. Pattern matching on Trailhead cannot be fully exhaustive if an unmarked subclass may be extended elsewhere; mark leaves final or sealed.

  • open is not a visibility modifier. It does not say where a class can be used, only that it may be extended.

  • Case classes inheriting from other case classes are forbidden: case class B(...) extends A(x) with A a case class fails with "case-to-case inheritance is prohibited"; use a trait or an abstract class as the parent. See Case Classes.

  • The warning needs -feature. Without the flag, the compiler may only print a summary line asking you to re-run with it, or nothing; treat it as an error in CI by enabling -feature -Werror.

Where the books are outdated

The books consulted for this section (see the section bibliography) were written for Scala 2 or for the first Scala 3 releases. For inheritance, treat these points as superseded by the pages cited here:

  • Early initializers (extends { val x = ... } with T) are Scala 2 and are errors in Scala 3; use trait parameters.

  • open does not exist in the Scala 2 books, which treat a plain class as freely extensible.

  • sealed, final, abstract and override are unchanged since Scala 2, and the explanations still hold; the then and braceless syntax of the examples is what differs.

  • Case-class inheritance and enumerations are handled differently: Scala 3 prefers enum and final case class hierarchies over sealed abstract class with Enumeration.

The run results above are from 2026-10-15 on Scala 3.10.0 and were not re-run on the 3.9 LTS or 3.3 LTS releases.