Annotations & Visibility

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). Two items are newer than the 3.3 LTS line: @publicInBinary was introduced experimentally in 3.4 and stabilized in 3.7.0, and named arguments are required for multi-element Java annotations from 3.6. Both are therefore available on the 3.9 LTS but not on 3.3. Everything else works on all three lines.

An annotation attaches metadata to a definition, a type or an expression. Some annotations are instructions to the compiler (@tailrec, @targetName), some are read at run time through reflection (Java annotations kept at run time), and some only document intent (@deprecated). Visibility modifiers decide which code may refer to a member. For the entry-point annotation @main see Hello World & Main Methods, and for the packages that qualified visibility refers to see Packages, Imports & Top-Level Definitions.

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

Annotation syntax

An annotation is written @Name or @Name(args) before the definition it applies to. On an expression or a type, the annotation follows a colon: (x: @unchecked). Annotations are ordinary classes that extend scala.annotation.Annotation, usually StaticAnnotation (kept in the class file for tools) or ConstantAnnotation (arguments must be constants). The scala.annotation package is documented in the Scala API. Names such as @main, @deprecated and @throws are available without an import; others are imported from scala.annotation.

Standard annotations

@main

@main turns a method into a program entry point, as every example on this page does. The details are on the Hello World page.

@tailrec

@tailrec asks the compiler to verify that a method is tail-recursive and to fail the build if it is not (see Recursion & Tail Calls).

//> using scala 3.10.0

import scala.annotation.tailrec

@tailrec
def bad(n: Int): Int =
  if n == 0 then 0 else 1 + bad(n - 1)

@main def a2(): Unit = println(bad(3))
-- Error: a2.scala:7:31
7 |  if n == 0 then 0 else 1 + bad(n - 1)
  |                            ^^^^^^^^^^
  |                 Cannot rewrite recursive call: it is not in tail position
1 error found
Compilation failed

Without the annotation the compiler would still optimize a method that happens to be tail-recursive, but would not tell you when a later edit removes that property.

@deprecated

@deprecated(message, since) marks a definition. Using it produces a deprecation warning that names the message:

//> using scala 3.10.0

@deprecated("use plan2", "1.2")
def plan(km: Double): Int = (km * 12).toInt

@main def a3(): Unit = println(plan(2.0))
-- Deprecation Warning: a3.scala:6:31
6 |@main def a3(): Unit = println(plan(2.0))
  |                               ^^^^
  |                            method plan is deprecated since 1.2: use plan2
1 warning found
24

Without -deprecation Scala CLI prints the one-line summary "there was 1 deprecation warning; re-run with -deprecation for details" and still runs the program. In Scala CLI the option is passed as -O -deprecation or with //> using options -deprecation.

@throws

The JVM records checked exceptions in a method signature, but Scala has no checked exceptions. @throws (in the scala package, so no import is needed) writes the exception into the generated signature so that Java callers may catch it.

//> using scala 3.10.0
import java.io.IOException

@throws[IOException]
def read(path: String): String =
  if path.isEmpty then throw IOException("empty") else "data"

@main def a5(): Unit =
  println(read("x"))
  val m = Class.forName("a5$package").getMethods.find(_.getName == "read").get
  println(m.getExceptionTypes.toList)
data
List(class java.io.IOException)

@targetName

@targetName("name") changes the name of a definition in the generated bytecode, without changing the name used in Scala source. Its main use is to disambiguate overloads that have the same signature after erasure:

//> using scala 3.10.0
import scala.annotation.targetName

object Overloads:
  def add(xs: List[Int]): Int = xs.sum
  def add(xs: List[String]): Int = xs.size

@main def a6(): Unit = ()
-- [E120] Naming Error: a6.scala:6:6
6 |  def add(xs: List[String]): Int = xs.size
  |      ^
  |Conflicting definitions:
  |def add(xs: List[Int]): Int in object Overloads at line 5 and
  |def add(xs: List[String]): Int in object Overloads at line 6
  |have the same type (xs: List): Int after erasure.
  |
  |Consider adding a @targetName annotation to one of the conflicting definitions
  |for disambiguation.

The fix, together with @tailrec and @threadUnsafe:

//> using scala 3.10.0

import scala.annotation.{tailrec, targetName, threadUnsafe}

@tailrec
def total(xs: List[Int], acc: Int = 0): Int = xs match
  case Nil => acc
  case h :: t => total(t, acc + h)

object Trails:
  @targetName("addTrail")
  def add(name: String): String = s"+$name"
  @targetName("addTrails")
  def add(names: List[String]): String = names.map(add).mkString(",")

  @threadUnsafe lazy val cache: Map[String, Int] = Map("Aguja" -> 3)

@main def a1(): Unit =
  println(total(List(1, 2, 3)))
  println(Trails.add("a"))
  println(Trails.add(List("a", "b")))
  println(Trails.cache)
6
+a
+a,+b
Map(Aguja -> 3)

The page Methods & Parameters shows the other main use, giving a symbolic operator a readable name for Java callers.

@threadUnsafe

A lazy val is initialized in a thread-safe way by default, which costs a little on every access. @threadUnsafe removes the synchronization when you know the value is only touched from one thread. The use in Trails.cache above compiles and behaves like a plain lazy val; the saving is only measurable in very hot code.

@publicInBinary

An inline method is expanded at each call site, so it may refer to members that are not public, and the compiler normally generates public accessors for them. @publicInBinary makes such a member public in the bytecode, which keeps the accessors out of the library’s binary interface. It was introduced experimentally in 3.4 and stabilized in 3.7.0 (SIP-52, announced in the 3.7.0 release notes).

//> using scala 3.10.0
import scala.annotation.publicInBinary

class Planner:
  @publicInBinary private[Planner] def speed: Double = 4.5
  inline def minutes(km: Double): Double = km / speed * 60

@main def a11(): Unit = println(Planner().minutes(9.0))
120.0

It matters mainly to library maintainers who check binary compatibility; application code seldom needs it.

@unchecked

@unchecked suppresses the exhaustivity and type-test warnings for the annotated scrutinee. Without it, matching only part of a union type warns, and an unmatched value fails at run time:

-- [E029] Pattern Match Exhaustivity Warning: a9.scala:7:19
7 |  val a = xs.map { case i: Int => i }.size       // warning: match may not be exhaustive
  |                   ^
  |                   match may not be exhaustive.
  |
  |                   It would fail on pattern case: _: String

The annotation goes on the scrutinee, written with a colon:

//> using scala 3.10.0
val xs: List[Int | String] = List(1, 2)
@main def a9b(): Unit =
  val b = xs.map { x => (x: @unchecked) match { case i: Int => i } }.sum
  println(b)
3

Use it only when an invariant you can state guarantees the match succeeds. Since Scala 3.8 the runtimeChecked extension method (SIP-57) is stable and is the more readable replacement for both (x: @unchecked) and refutable val patterns; it was experimental in the 3.6 and 3.7 lines (see the 3.6.2 release notes). It is covered in Extractors & Pattern Bindings.

@nowarn

@nowarn silences warnings in the annotated tree. A filter such as "cat=deprecation" limits it to one category, and an unfiltered @nowarn silences everything in scope, so prefer the filtered form:

//> using scala 3.10.0
import scala.annotation.nowarn

@deprecated("use plan2", "1.2")
def plan(km: Double): Int = (km * 12).toInt

@main def a4(): Unit =
  @nowarn("cat=deprecation")
  val r = plan(2.0)
  println(r)
  @nowarn val unused = plan(1.0)       // silences every warning in the annotated tree
  println(unused)
24
12

@experimental

A definition marked @experimental may be used only from other experimental code. Using it from ordinary code is an error:

//> using scala 3.10.0
import scala.annotation.experimental

@experimental def fancy: Int = 1

def user: Int = fancy

@main def a7(): Unit = println(user)
-- Error: a7.scala:6:16
6 |def user: Int = fancy
  |                ^^^^^
  |         method fancy is marked @experimental
  |
  |         Experimental definition may only be used under experimental mode:
  |           1. in a definition marked as @experimental, or
  |           2. an experimental feature is imported at the package level, or
  |           3. compiling with the -experimental compiler flag.
1 error found
Compilation failed

Marking the caller @experimental as well makes it compile; that infection is deliberate, so that stable code cannot silently depend on a feature that may change. The entry point @main def may stay stable as long as it does not call the experimental code (a variant compiled and ran, printing the text from its body).

Java annotations

Java annotations are read through reflection when they have run-time retention. They are written like Scala annotations, but since Scala 3.6 named arguments are required when the Java annotation declares more than one element, because the compiler used to rely on the order of the elements and a reordering on the Java side silently changed the meaning. A single element named value may still be passed positionally. Given these two Java annotations (compiled with javac and put on the class path):

@Retention(RetentionPolicy.RUNTIME)
public @interface Source {
    String url();
    String mail() default "none";
}

@Retention(RetentionPolicy.RUNTIME)
public @interface Tag {
    String value();
}

they are used from Scala like this:

//> using scala 3.10.0

@Source(url = "http://localhost/trails", mail = "dev@localhost")
class Planner

@Source(url = "http://localhost/other")
class Other

@Tag("hiking")
class Tagged

@main def a10(): Unit =
  val s = classOf[Planner].getAnnotation(classOf[Source])
  println(s.url + " " + s.mail)
  println(classOf[Other].getAnnotation(classOf[Source]).mail)
  println(classOf[Tagged].getAnnotation(classOf[Tag]).value)
http://localhost/trails dev@localhost
none
hiking

Positional arguments for Source are rejected on 3.10.0:

-- [E201] Syntax Error: a10b.scala:3:8
3 |@Source("http://localhost/trails", "dev@localhost")
  |        ^^^^^^^^^^^^^^^^^^^^^^^^^
  | Named arguments are required for Java defined annotations
  | This can be rewritten automatically under -rewrite -source 3.6-migration.

Visibility

Scala members are public by default; there is no public keyword. Two modifiers restrict access:

  • private allows access only from the enclosing class or object, and from its companion. Other instances of the same class are allowed.

  • protected allows access from the class and its subclasses. Unlike Java, it does not grant access to the rest of the package, and access through an instance of the superclass from a subclass is not allowed either.

//> using scala 3.10.0
package trails

class Trail(val name: String, private val grade: Int, protected val ascent: Int):
  private[trails] val internalId = 7                 // visible in package trails
  protected[trails] def audit = "audit"              // subclasses plus package trails
  private var visits = 0
  def isHarder(o: Trail): Boolean = grade > o.grade  // class-private: other instances are fine
  def visit(): Int = { visits += 1; visits }

object Trail:
  def gradeOf(t: Trail): Int = t.grade                // companion sees class-private members

class Summit(n: String) extends Trail(n, 3, 900):
  def climb: Int = ascent                            // protected: visible in a subclass

@main def a12(): Unit =
  val t = Trail("Aguja", 3, 500)
  println(t.isHarder(Trail("Cares", 2, 100)))
  println(Trail.gradeOf(t))
  println(Summit("Pico").climb)
  println(t.internalId)
true
3
900
7

Qualified modifiers

A modifier may name an enclosing package, class or object in brackets, which widens the access to that scope:

  • private[trails] allows access from anywhere inside the package trails, including its sub-packages.

  • protected[trails] allows subclasses plus everything in trails, which is the closest match to Java’s protected.

  • private[Planner] names the enclosing class and is equivalent to plain private for a member of Planner.

  • The form private[this] (object-private, Scala 2) still compiles but reports a warning, because the plain private is now enough:

-- Warning: a14.scala:3:9
3 |  private[this] val y = 1
  |         ^^^^^^
  |Ignoring [this] qualifier.
  |The syntax `private[this]` will be deprecated in the future; just write `private` instead.
  |See: https://docs.scala-lang.org/scala3/reference/dropped-features/this-qualifier.html

What the errors look like

From a different package, a private, a protected and a package-private member are all rejected, each with its own explanation (other imports trails.Trail):

//> using scala 3.10.0
package trails:
  class Trail(private val grade: Int, protected val ascent: Int):
    private[trails] val internalId = 7

package other:
  import trails.Trail
  @main def a13(): Unit =
    val t = Trail(1, 2)
    println(t.grade)
    println(t.ascent)
    println(t.internalId)
-- [E173] Reference Error: a13.scala:10:14
10 |    println(t.grade)
   |            ^^^^^^^
   |value grade cannot be accessed as a member of (t : trails.Trail) from the top-level definitions in package other.
   |  private value grade can only be accessed from class Trail in package trails.
-- [E173] Reference Error: a13.scala:11:14
11 |    println(t.ascent)
   |            ^^^^^^^^
   |value ascent cannot be accessed as a member of (t : trails.Trail) from the top-level definitions in package other.
   |  protected value ascent can only be accessed from class Trail in package trails or one of its subclasses.
-- [E173] Reference Error: a13.scala:12:14
12 |    println(t.internalId)
   |            ^^^^^^^^^^^^
   |value internalId cannot be accessed as a member of (t : trails.Trail) from the top-level definitions in package other.
   |  private[trails] value internalId can only be accessed from package trails.
3 errors found
Compilation failed

A protected member is also not reachable from the same package unless the code is in a subclass:

-- [E173] Reference Error: a16.scala:8:24
8 |  def run(t: Trail) = t.audit      // same package, not a subclass
  |                      ^^^^^^^
  |method audit cannot be accessed as a member of (t : trails.Trail) from object Check.
  |  protected method audit can only be accessed from class Trail in package trails or one of its subclasses.

And a subclass may use a protected member only through its own type, not through an arbitrary instance of the parent:

-- [E173] Reference Error: a15.scala:5:21
5 |  def peek(o: A) = o.hidden        // protected: not accessible through a different instance of the superclass
  |                   ^^^^^^^^
  |method hidden cannot be accessed as a member of (o : A) from class B.
  |  protected method hidden can only be accessed from class B or one of its subclasses.

Pitfalls

  • Forgetting that annotations without an import fail. Most annotations need import scala.annotation.*; the annotation @throws lives in the scala package, and scala.annotation.throws does not exist (the compiler says "value throws is not a member of annotation").

  • Positional arguments for a Java annotation with several elements, as shown above.

  • Assuming protected means package access as in Java.

  • Using @nowarn without a filter, which hides unrelated warnings added later.

  • Treating @experimental as harmless: anything that uses it becomes experimental too, which can spread through an API.

Differences from other languages

  • Java annotations are written in a similar way, @Source(url = "x"), and Scala can read and write them. Java has no equivalent of @tailrec or @targetName, and its protected also grants package access.

  • Kotlin uses @Throws as the counterpart of @throws and has private, protected and internal; Scala’s qualified private[pkg] covers the role of internal.

  • Scala 2 has the same annotations, with private[this] and protected[this] in common use and without @targetName or @publicInBinary.

Where the books are outdated

  • Older books use private[this] and protected[this]; on 3.10.0 the former only warns and plain private suffices.

  • The Scala 2 form of a Java annotation with several elements passed them positionally; on 3.6 and later they must be named.

  • @publicInBinary and @targetName do not appear in Scala 2 books, and @experimental is a Scala 3 mechanism.

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.