In Scala, def inc(x: Int) = x + 1 and val inc = (x: Int) => x + 1 look interchangeable. You can call both as inc(2) and pass both to map. They are still different things. The first is a method, a member of a class, trait or object that exists only as code attached to its owner. The second is a function value, an ordinary object that implements a FunctionN trait and can be stored, passed and returned like any other value.

The compiler converts between the two so smoothly that most code never notices. That smoothness is also why the differences surface as confusing bugs: listeners that will not unregister, default arguments that disappear, and NullPointerExceptions during construction. This article explains the model, the exact eta-expansion rules in Scala 2 and Scala 3, what is lost in the conversion, and how to choose between def and val for behaviour. For the combinator side (map, fold, composition) and how lambdas compile to JVM bytecode, read Scala higher-order functions next.

Advertisement

Two things that look the same

A method belongs to something. It is compiled to a JVM method on its owner's class, and when you call it the owner, this, is implicitly available. A method is not a value: you cannot put "the method" itself in a list. When you write a method name where a value is expected, the compiler has to build a function object that calls the method. That conversion is called eta-expansion.

A function value is an instance of Function1[-A, +B] (or Function2 and so on), whose apply method runs the body. f(x) is sugar for f.apply(x). Because it is an object, it has a runtime class, an identity, and methods of its own such as andThen and compose.

Method: def add(a: Int, b: Int = 1)member of a class, trait or objecttype params, defaults, named argsoverloading, by-name, varargs, usingno identity, not a valueJVM: a method on the classFunction value: (Int, Int) => Intan object with an apply methodstorable, passable, returnableandThen, compose, curried, tupledhas identity; defaults are goneJVM: instance of FunctionNeta-expansionf(x) calls applyEta-expansion builds (a, b) => add(a, b)a new object each time; may capture thisLost on the way: default arguments, parameter names, overload choice, and (Scala 2) type parameters
A method is a member with rich parameter features but no identity. A function value is an object. Eta-expansion bridges them by creating a new lambda that calls the method, and some method features do not survive the trip.

What only methods can do

FeatureMethod exampleFunction value?
Type parametersdef first[A](xs: List[A]): Option[A]Scala 2: no. Scala 3: yes, with polymorphic function types
Default argumentsdef greet(n: String, g: String = "Hi")No, the defaults are gone after expansion
Named argumentsgreet(g = "Yo", n = "Ann")No, only positional calls
Overloadingdef abs(x: Int) and def abs(x: Double)No, one function has one type
Varargsdef sum(xs: Int*)No, it becomes a Seq[Int] => Int
Implicit or using parametersdef show(x: A)(using Show[A])Scala 3 has context function types ?=>
Access to this without captureAny instance methodA lambda that uses members captures this

By-name parameters are the one feature that does survive. A method def retry(op: => Int): Int expands to a function of type (=> Int) => Int, and the argument is still evaluated lazily when the function is called.

Advertisement

What only function values can do

Function values are first-class. You can keep them in a Map[String, Int => Int], return them from a method, build them at run time from configuration, and combine them with andThen, compose, .curried and .tupled. They can close over local variables and outlive the scope that created them. Every higher-order API in the collections library takes function values, which is why eta-expansion happens so often without you noticing.

val parse: String => Int = _.trim.toInt
val double: Int => Int   = _ * 2
val pipeline = parse andThen double          // String => Int
val add3: (Int, Int, Int) => Int = _ + _ + _
val addC = add3.curried                      // Int => Int => Int => Int
val handlers: Map[String, String => Int] = Map("parse" -> parse, "len" -> (_.length))

Eta-expansion rules in Scala 2 and Scala 3

In Scala 2.13, a method with parameters is converted to a function automatically only when the expected type is a function type. Writing val f = inc with no expected type is an error: the compiler says it is missing an argument list and suggests inc _ or inc(_). Passing inc to map works because map expects a function.

Scala 3 simplifies this. The Scala 3 reference states that a method with one or more parameters is always eta-expanded, so val f = inc now gives Int => Int. The method value syntax inc _ is deprecated because it is no longer needed. Methods with an empty parameter list are the exception. def now(): Long expands only if the expected type is () => Long. If the method is defined in Java, or overrides a Java method, the compiler inserts () and calls it. Otherwise it reports that the method must be called with a () argument. This follows from Scala 3 dropping auto-application: now no longer silently means now() for methods defined in Scala 3.

def inc(x: Int): Int = x + 1
def now(): Long = System.nanoTime()

val f = inc                    // Scala 3: Int => Int.  Scala 2.13: error, write inc _
val g: () => Long = now        // expected type () => Long, so now is eta-expanded
val h = () => now()            // explicit and clear in both versions

Implicit and context parameters need care. The Scala 3 reference says a method with an old-style implicit parameter list is applied to its implicit arguments during eta-expansion. The implicits are therefore resolved where you write the expansion, not where the function is later called. A method with a using clause expands to a context function only when you give an expected type such as Int => Show[Int] ?=> String. Annotate the type whenever you want callers to supply the given later.

What gets lost, with examples

Because the result of eta-expansion is a plain function, everything that belonged to the method's signature rather than its type disappears.

def greet(name: String, greeting: String = "Hello"): String = s"$greeting, $name"

greet("Ann")                          // "Hello, Ann"
val g = greet                         // (String, String) => String
// g("Ann")                           // does not compile: two arguments required
// g(greeting = "Hi", name = "Ann")   // does not compile: functions have no parameter names

val abs1: Int => Int = math.abs       // expected type picks the Int overload
// val abs2 = math.abs                // ambiguous overload: no expected type to choose

The fix is always the same: when a function needs a default or a particular overload, write the lambda yourself, for example val hello: String => String = greet(_), which fills in the default inside the body. The same applies to varargs, which become a Seq parameter, so callers of the function must pass a collection.

Polymorphic methods and polymorphic function types

In Scala 2, a function value cannot be generic. Eta-expanding def first[A](xs: List[A]): Option[A] fixes A at the expansion site, and without an expected type you usually get List[Nothing] => Option[Nothing], which is useless. The workaround was a trait with a generic apply method, the pattern libraries use for natural transformations.

Scala 3 adds polymorphic function types, which make a generic function a real value:

val first: [A] => List[A] => Option[A] =
  [A] => (xs: List[A]) => xs.headOption

first(List(1, 2, 3))      // Some(1)
first(List("x"))          // Some("x")

Use them when you need to pass genericity itself, for example a transformation applied to several differently typed fields. For everything else a generic method is simpler and gives clearer error messages. For how the compiler infers A and where inference stops, see the Scala type system in depth.

Worked example: the listener that will not unregister

A small event bus stores listeners in a set and removes them by value. A UI component registers its handler method on start and unregisters it on stop:

final class Bus[E]:
  private var listeners = Set.empty[E => Unit]
  def subscribe(l: E => Unit): Unit   = listeners += l
  def unsubscribe(l: E => Unit): Unit = listeners -= l
  def publish(e: E): Unit             = listeners.foreach(_(e))

final class Panel(bus: Bus[String]):
  private def onEvent(e: String): Unit = println(s"panel got $e")
  def start(): Unit = bus.subscribe(onEvent)     // eta-expansion #1
  def stop(): Unit  = bus.unsubscribe(onEvent)   // eta-expansion #2: a different object

After stop(), the panel still receives events, and it can never be garbage-collected because the bus holds a lambda that captured it. The cause is that each mention of onEvent builds a new function object. Function values use reference equality, so the second object is not equal to the first and -= removes nothing. The fix is to create the function once and keep it:

final class Panel(bus: Bus[String]):
  private val handler: String => Unit = e => println(s"panel got $e")
  def start(): Unit = bus.subscribe(handler)
  def stop(): Unit  = bus.unsubscribe(handler)   // same object, removal works

A better API makes this impossible: have subscribe return a handle with a cancel() method, so callers never need function identity at all. The general rule is never to depend on two separate eta-expansions of the same method being equal.

def, val and lazy val for behaviour

When a member's type is a function, there are three ways to define it, and they differ in when the object is created. A def op: Int => Int = ... evaluates its body on every access. That is cheap for a non-capturing lambda, but it can allocate for a capturing one. A val creates the function once, during construction. A lazy val creates it on first use, with a small synchronisation cost.

The val option has a trap: initialisation order. A trait's body runs before the subclass's body, so a trait val that uses an abstract member sees null:

trait Handler:
  val transform: String => String
  val pipeline: String => String = transform.andThen(_.trim)   // runs first

class Upper extends Handler:
  val transform: String => String = _.toUpperCase

new Upper   // NullPointerException: transform is still null in Handler's body

Define pipeline as a def or lazy val, or make transform an abstract def that the subclass implements as a method. Declaring behaviour as methods and converting to functions only at the edges of an API avoids the whole class of problem.

Cost model

A method call is a JVM method call that the JIT inlines easily. Calling through a function value adds an interface call to apply, which the JIT also inlines when a call site sees only one or two lambda classes. It gets slower when one hot site sees many different lambdas. Eta-expanding an instance method creates a lambda that captures this, so doing it inside a hot loop allocates on every iteration. Hoist it to a val outside the loop. Generic code that goes through type parameters boxes primitive arguments. If a profiler shows boxing or allocation in such a path, a specialised method is the usual fix. Measure with JMH before changing code for speed, because in most applications the difference is noise. Scala collections performance covers the same effects in collection pipelines.

Failure modes and design guidance

  • Identity-based removal of callbacks created by eta-expansion never matches. Keep the function in a val or return a cancel handle.
  • Silently lost defaults when a method with defaults is passed as a function. Write the lambda explicitly.
  • Implicits resolved too early by eta-expanding a method with an implicit list. Use a context function type or pass the method at the call site.
  • Trait val initialisation nulls for function members built from abstract members. Use def or lazy val.
  • Accidental retention: a stored lambda that captured this keeps the whole object alive.
  • Nullary confusion during a Scala 3 migration: now no longer means now(). Add the parentheses or an explicit () => now().

As a design rule, define behaviour as methods on types, because they document better, support defaults and overloading, and have no initialisation surprises. Use function values when behaviour is data: strategies chosen at run time, callbacks, pipelines and anything you store. Where both are needed, expose the method and create the function once at the boundary. Givens and context functions build on these rules, as covered in Scala 3 givens.

What to do next

  1. Open a Scala 3 REPL and try val f = inc, val g = now and val h: () => Long = now to see the three eta-expansion outcomes yourself.
  2. Search your code for -= or remove calls on collections of functions and check that each removes the same object that was added.
  3. Find methods with default arguments that are passed as functions, and replace them with explicit lambdas.
  4. Review traits for val members that depend on abstract members, and turn them into def or lazy val.
  5. Before a Scala 3 migration, compile with migration warnings enabled to find auto-applied nullary methods and m _ method values.
  6. Profile one hot path that passes methods as functions and hoist any eta-expansion found inside a loop.
Key takeaway: A method is a member with rich parameter features: type parameters, defaults, named arguments, overloading and using clauses. A function value is an object with identity that you can store and combine. Eta-expansion bridges them by building a new lambda each time, which Scala 3 does automatically for methods with parameters but not for nullary ones. The conversion drops defaults, names and overload choice, and it resolves old-style implicits early. Write behaviour as methods, create function values once at API boundaries, never rely on two expansions being equal, and avoid trait vals that depend on abstract members.