Table of Contents
The Problem: Hidden Defaults
Someone asked on the Scala contributors forum how to access method parameter defaults.
Default parameters are convenient syntax sugar, but sometimes you need to access them programmatically. Imagine you’re building a configuration builder and want to include the default values in help text:
case class Database(host: String = "localhost", port: Int = 5432, name: String = "mydb")
// You want to generate documentation:// "host" (default: localhost)// "port" (default: 5432)// "name" (default: mydb)We’d like to pass a method reference and get back a Map[String, Any] containing all parameter defaults.
@maindef main(): Unit = assert(defaults(method) == Map("x" -> 10, "y" -> "two"))
def method(x: Int = 10, y: String = "two") = ???Prerequisites
With this goal in mind, let’s ensure you have the right background knowledge before diving into the implementation. I assume you’re comfortable with Scala and have some familiarity with Scala 3 macros. If you need a refresher on Scala 3 macros, I recommend this Software Mill article first.
I use Scala 3.8.0-RC4 for this example.
How Scala Encodes Defaults
Since the JVM doesn’t natively support default parameters, Scala gets creative. When you define a method with defaults, the compiler generates companion methods in the class for each defaulted parameter. These methods are named with a special encoding: $default$N, where N is the parameter position (1-indexed).
So def method(x: Int = 10, y: String = "two") = ??? actually creates:
//// Source code recreated by IntelliJ IDEA// (powered by FernFlower decompiler)//// decompiled from main$package$.classimport java.io.Serializable;import scala.runtime.Nothing;import scala.runtime.Scala3RunTime.;
public final class main$package$ implements Serializable { // ... other generated code ...
public Nothing method(final int x, final String y) { return scala.Predef..MODULE$.$qmark$qmark$qmark(); }
public int method$default$1() { return 10; }
public String method$default$2() { return "two"; }}Scala compiler on call site generates calls to these $default$N methods when parameters are omitted. Understanding
this encoding is crucial because our macro will hunt for these hidden $default$N methods. With that foundation, let’s
build the first version.
Step 1: Simple Version (Map-Based)
Let’s move to the code. We will use the quotes.reflect to look under the hood of the method definition at compile
time. Our macro does three things:
- extracts the symbol of method we’re analyzing,
- finds all generated
$default$Nmethods, - builds a map connecting parameter names to their default values.
Here’s the code with detailed comments:
inline def defaults[T](inline fun: T): Map[String, Any] = ${ defaultsImpl('{ fun }) }
def defaultsImpl[T: Type](expr: Expr[T])(using quotes: Quotes): Expr[Map[String, Any]] = import quotes.reflect.*
// Extract the method reference from the inline parameter // Lambda(...) = the anonymous function wrapper, Apply(...) = the function call val Lambda(_, Apply(method, _)) = expr .asTerm // convert Expr[?] to the Reflection Term .underlying // get the real method behind the inline wrapper .runtimeChecked // disable the exhaustiveness check (we assume happy path here)
// Get the method's symbol (compile-time metadata about the method) val methodSymbol = method.symbol
// Collect all parameter names in order val paramNames = methodSymbol.paramSymss // "parameter symbol sequences" (handles grouped parameters) .flatten // combine all groups into one list .map(_.name) // extract just the names .toVector // convert to Vector for indexed access (we need positions later)
// Build the prefix for hidden default methods val prefix = methodSymbol.name + "$default$"
// Find all hidden default methods and map them to parameter names val defaults = methodSymbol.owner.methodMembers .collect: case m if m.name.startsWith(prefix) => // Only collect methods starting with our prefix (e.g., "greet$default$1") val position = m.name.stripPrefix(prefix).toInt // Extract the position number paramNames(position - 1)-> Ref(m). // Ref(m) creates an expression that can call this method at runtime
// Convert each (paramName, methodRef) pair to an expression tuple // This prepares them for splicing into the macro result .map: (k, v) => Expr.ofTuple((Expr(k), v.asExpr)) //Expr lifts String into Expr[String], asExpr converts Term to Expr[?]
// Varargs(...) converts List[Expr[T]] to Expr[List[T]] '{ ${ Varargs(defaults) }.toMap }This macro successfully extracts defaults into a Map. Let’s see it in action:
Testing the First Version
def greet(name: String = "samsepi0l", number: Int = 43) = s"$greeting $number"
@maindef main(): Unit = val d = defaults(greet) println(d) // Map("name" -> "samsepi0l", "number" -> "43")
// Access via string key (but no type safety) val name = d("name") val number: Int = d("number").asInstanceOf[Int] // type is Any, must castWhy We Need Better: Type-Safety
Our first solution works, but it has drawbacks: we can get a runtime error when key does not exist and the type of value
is always Any.
Step 2: Type-Safe Version with Computed Field Names
To solve these issues, we’ll use two powerful Scala 3 features: Selectable and Computed Field Names.
What is Selectable?
Selectable is a trait that enables dynamic access to the refined fields.
The selectDynamic method takes a field name and returns the value associated with that name.
class DynamicConfig extends Selectable: val x = 10 val y = "hello"
def selectDynamic(name: String): Any = name match case "x" => x case "y" => y case _ => throw new NoSuchFieldException(s"No such field: $name")
import scala.reflect.Selectable.reflectiveSelectable
val d: Selectable { val x: Int; val y: String; def selectDynamic(name: String): Any } = new DynamicConfigval x: Int = d.x // worksval y: String = d.y // worksval z = d.z // compile error: no z fieldThis solution is type-safe because the compiler knows the types of x and y at compile time, but it requires us to
import implicit conversion that turns a value into a Selectable such that structural selections are performed on that
value.
What are Computed Field Names?
This basic Selectable gives us dynamic access, but lacks type safety. That’s where computed field names come in. They
let us encode field types at compile-time.
The Selectable trait now can have a Fields type member that can be instantiated to a named tuple.
trait Selectable: type Fields <: NamedTuple.AnyNamedTupleIf Fields is instantiated in a subclass of Selectable to some named tuple type, then the available fields and their
types will be defined by that type. For example, if Fields is defined as (x: Int, y: String), then the Selectable
instance will have fields x of
type Int and y of type String.
class DynamicConfig extends Selectable: type Fields = (x: Int, y: String)
private val data = Map("x" -> 10, "y" -> "hello")
def selectDynamic(name: String): Any = data(name)
val d = new DynamicConfigval x: Int = d.x // worksval y: String = d.y // works// val z = d.z // compile error: no z fieldWith Selectable and Computed Field Names understood, let’s enhance our macro to automatically generate the Fields
type for any method’s defaults.
The Type-Safe Implementation
We’re gonna enhance our macro to build a Fields type member that is a named tuple of parameter names and types.
Example: for method(x: Int = 10, y: String = "hi") names should become: ("x", "y"), types should become:
(Int, String).
The result should be (x: Int, y: String) (which is a syntax sugar for NamedTuple[("x", "y"), (Int, String)]).
import scala.NamedTuple.{AnyNamedTuple, NamedTuple}import scala.quoted.*
// 'transparent' is key: type information flows through to the caller// Without it, type refinements would be hidden inside the return typetransparent inline def defaults[T](inline fun: T): DefaultsExtractor = ${ defaultsImpl('{ fun }) }
def defaultsImpl[T: Type](expr: Expr[T])(using quotes: Quotes): Expr[DefaultsExtractor] = import quotes.reflect.*
// Extract method symbol (same as before) val Lambda(_, Apply(method, _)) = expr.asTerm.underlying.runtimeChecked val methodSymbol = method.symbol val prefix = methodSymbol.name + "$default$" val paramNames = methodSymbol.paramSymss.flatten.map(_.name).toVector
// Build runtime map (same as before) val defaults = methodSymbol.owner.methodMembers .collect: case m if m.name.startsWith(prefix) => paramNames(m.name.stripPrefix(prefix).toInt - 1) -> Ref(m)
// Convert to expression that builds a Map at runtime val defaultsExpr = val list = Expr.ofSeq: defaults.map: (name, term) => Expr.ofTuple((Expr(name), term.asExpr))
'{ $list.toMap }
val fieldsType = TypeRepr .of[NamedTuple] .appliedTo: defaults // Start with empty tuples: () and () .foldLeft((TypeRepr.of[EmptyTuple], TypeRepr.of[EmptyTuple])): case ((accNames, accTypes), (paramName, term)) => // For each (paramName, defaultMethod) pair, build up both tuples
// Build the names tuple by cons-ing the parameter name. *: is Scala's tuple cons operator (like :: for lists at type level) val newNames = TypeRepr.of[*:].appliedTo(List( ConstantType(StringConstant(paramName)), // convert a string to a type accNames // previously accumulated names ))
//Similarly for types val newTypes = TypeRepr.of[*:].appliedTo(List( term.tpe, // the type of the default value's expression accTypes // previously accumulated types ))
(newNames, newTypes) // After the fold, convert the (names, types) pair to a List // for passing to .appliedTo .toList
// Pattern match to "escape" the type into the macro's context // asType match converts TypeRepr to a compile-time Type fieldsType.asType match // Pattern introduces a type variable 'fields' capturing what we built (with bounds to AnyNamedTuple) case '[ type fields <: AnyNamedTuple; fields ] => '{ new DefaultsExtractor($defaultsExpr): type Fields = fields // Refine the abstract type to our computed one }
// The extractor class that makes everything worksealed class DefaultsExtractor(defaults: Map[String, Any]) extends Selectable: // This type member will be refined by each macro invocation // to the exact NamedTuple of defaults for that specific method type Fields <: AnyNamedTuple
// The magic method: enables d.x, d.y syntax // When you write d.x, the compiler expands it to: // selectDynamic("x").asInstanceOf[T] // where T is the type of "x" from the Fields type member final def selectDynamic(name: String): Any = defaults(name)This code works because of a crucial detail: the transparent keyword.
Why transparent inline def?
The transparent keyword means the inline function doesn’t create a type boundary. Type refinements (the
type Fields = ... part) flow through to the caller’s context. Without it, the detailed field information would be
hidden inside the DefaultsExtractor’s type, and callers couldn’t see which fields are available.
Edge Cases & Production Considerations
This implementation handles the happy path. It may crash on varargs, implicits, nested functions or in combination with access modifiers.
Case closed
You can get the code on GitHub. Back to sleep now. Would you like me to write something here again?