Engineering

An expect declaration cannot carry default arguments, and the error says otherwise

Defaults live on the expect side; the diagnostic points at the actual.

MobileByteSensei 10 min read
kmpkotlinarchitecture

TL;DR

  • Moving a default argument from an actual to an expect produced a compiler error pointing at the actual, with a message about the actual's parameters — so four of us spent an afternoon editing the wrong file.
  • The rule is simple and the diagnostic hides it: defaults belong to the expect and may not appear on the actual. The error is reported where the violation is detected, not where it is.
  • Once the rule is stated, a second consequence follows that nobody had noticed: a default on the expect is applied at the Kotlin call site, so it also does not reach Swift — the same erasure as any other default.
  • What reading on buys: which side owns each part of a signature, the three errors and their real causes, and the one case where the rule bites on a constructor instead.

The case this post is about

A platform logger with a level that most callers do not pass:

// commonMain/kotlin/log/Logger.kt
expect class Logger {
    fun log(message: String, level: Level)
}
// androidMain — the default lives here, because this is where it was written first
actual class Logger {
    actual fun log(message: String, level: Level = Level.Info) {
        android.util.Log.println(level.priority, TAG, message)
    }
}

That compiles on Android alone, and log("hello") works in androidMain. It does not work in commonMain, because the expect has no default — which is correct and is where the afternoon started.

The symptom

Adding the default to the expect, which is the right move:

expect class Logger {
    fun log(message: String, level: Level = Level.Info)
}
e: An expected function with default parameter values should not have actual declarations
   with default parameter values
   androidMain/kotlin/log/Logger.android.kt:7:41

The error is at Logger.android.kt:7:41 — the actual — and the message's subject is "actual declarations with default parameter values". So the natural reading is the actual is the problem, and the natural fix is to look at the actual and wonder what is wrong with it.

What is wrong with it is that it also has a default, and it is wrong because the expect now has one. The error is about a relationship and it is reported at one end.

The second symptom, from removing it from the expect instead:

// back to: expect fun log(message: String, level: Level)
// keeping:  actual fun log(message: String, level: Level = Level.Info)
e: Actual function cannot have default argument values, they should be declared in the
   expected function
   androidMain/kotlin/log/Logger.android.kt:7:41

Same location, and this message states the rule outright. Two errors, both at the actual, with opposite causes — and we hit them in the order that produced the uninformative one first.

The four combinations of a default on each side of an expect-actual pair, with both error states reported at the actual and only one of the two messages naming the rule

Why the obvious fix failed

The first attempt was to leave the default off both sides and add an overload on the expect:

expect class Logger {
    fun log(message: String, level: Level)
    fun log(message: String)
}
e: Expected class 'Logger' has no actual declaration in module <app> for Native
   (the second overload was implemented on Android only)

Which is a real error about a real omission, and it sent the investigation sideways again because it is a third message at a different location. The overload approach does work and requires implementing both on all five targets, which is the thing a default was meant to avoid.

The second attempt was to make the parameter nullable and resolve the default in the body:

expect class Logger {
    fun log(message: String, level: Level?)
}
// each actual: val l = level ?: Level.Info

Compiles, and now the default is duplicated across five actuals with nothing checking that they agree. We shipped this for two weeks and one actual used Level.Debug, so the web target logged at a different level than the other four and nobody noticed until a log volume alert.

The third attempt was correct and the reason it took a while is the diagnostic: putting the default on the expect and removing it from every actual. Both halves are required, and the error only ever names one of them.

The actual mechanism

An expect/actual pair is one declaration with two halves, and the halves own different parts of it.

The expect owns the signature as callers see it. Parameter names, parameter order, default values, visibility, and — for a class — which members exist. Everything a call site needs to resolve against.

The actual owns the implementation. The body, and platform-specific additions that are not part of the common contract.

A default argument is part of the signature, not the implementation. It is applied at the call site by the compiler, so the call site must be able to see it — and a call site in commonMain can see only the expect. A default on the actual would be a default visible to that platform's source set and invisible to common, which is not a contract; it is a per-platform divergence in what the function means.

So the rule follows: defaults on the expect, never on the actual. And the diagnostic reports it at the actual because the actual is where the compiler finds the surplus = Level.Info while checking the pair.

And the consequence nobody had noticed. Once the default is on the expect, it is still a call-site transformation — so the exported Obj-C method has the full parameter list and Swift gets no default, exactly as for any other Kotlin default. Moving it to the correct side fixed the compile error and did not fix the Swift call sites, which had been passing level explicitly all along and would keep doing so.

That is worth separating clearly, because the two problems have the same shape and different fixes:

  default on the actual        → a compile error, at the wrong-looking place
  default on the expect        → correct, and still does not reach Swift
  an explicit expect overload  → reaches Swift, and must be implemented five times

Which half of the pair owns which part of a declaration, with the expect owning everything a call site resolves against and the actual owning the body, so a default argument belongs to the signature rather than the implementation

The fix

Default on the expect, nowhere on the actuals, and — where Swift is a real consumer — an explicit overload with the default value named once.

// commonMain/kotlin/log/Logger.kt
expect class Logger {

    /**
     * `level` defaults HERE and only here. An `= Level.Info` on any actual is a compile
     * error reported at that actual, with a message that reads as though the actual is at
     * fault — it is the pair that is, and this side is the one that may carry it.
     */
    fun log(message: String, level: Level = Level.Info)

    /**
     * An explicit overload, not a default, because a default is applied at the Kotlin call
     * site and so does not cross to Swift. This one is a real function and exports as a real
     * selector.
     */
    fun log(message: String)
}
// androidMain/kotlin/log/Logger.android.kt
actual class Logger(private val tag: String) {

    // NO default here. The signature's default is the expect's; this side supplies behaviour.
    actual fun log(message: String, level: Level) {
        android.util.Log.println(level.priority, tag, message)
    }

    // The overload delegates, so there is exactly one place the value Level.Info appears
    // per platform — and a platform that forgot to delegate would be a behavioural
    // divergence, which is what the test below exists for.
    actual fun log(message: String) = log(message, Level.Info)
}

And the test that catches the divergence the nullable-parameter version shipped, because five actuals delegating correctly is a convention rather than a guarantee:

// commonTest/kotlin/log/LoggerDefaultTest.kt
//
// Asserts the one-argument overload and the defaulted call produce the SAME level, on every
// target. It is near-tautological per platform and it is cross-platform agreement that is
// being checked: the version this replaces had one actual resolving to Level.Debug, and the
// web target logged at a different level for two weeks.
class LoggerDefaultTest {

    @Test
    fun theOverloadAndTheDefaultAgree() {
        val sink = RecordingSink()
        val logger = Logger(tag = "test", sink = sink)

        logger.log("a")                       // the overload
        logger.log("b", Level.Info)           // the default, written out

        assertEquals(sink.records[0].level, sink.records[1].level,
            "the one-argument overload resolves to a different level than the expect's " +
            "default on this target — the two must agree or callers get platform-dependent " +
            "behaviour from an API that looks uniform")
    }
}

And the output it produced, run across the five targets with one actual deliberately left delegating to Level.Debug:

$ ./gradlew :core:log:allTests

> Task :core:log:jvmTest                  PASSED
> Task :core:log:androidUnitTest          PASSED
> Task :core:log:iosSimulatorArm64Test    PASSED
> Task :core:log:desktopTest              PASSED
> Task :core:log:wasmJsTest               FAILED
    the one-argument overload resolves to a different level than the expect's default
    on this target — expected Info but was Debug

Which is the two-week divergence, caught by the build.

How we proved it

That the error location is the relationship's end and not its cause — the thing that cost the afternoon, stated as a table rather than as a complaint:

  expect has a default   actual has a default   error                              reported at
        no                      no              none (but commonMain cannot omit)   —
        no                      YES             "Actual function cannot have
                                                 default argument values"           the actual
        YES                     YES             "An expected function with default
                                                 parameter values should not have
                                                 actual declarations with default…"  the actual
        YES                     no              none — correct                       —

Both error states point at the actual, and only one of the two messages names the rule. We met the other one first.

That a default on the expect still does not reach Swift, which is the half the compile fix does not address:

$ grep -c 'logMessage:' shared.framework/Headers/shared.h
1                               # with the default on the expect
$ grep 'logMessage' shared.framework/Headers/shared.h
- (void)logMessage:(NSString *)message level:(SharedLevel *)level;
                                        ↑ required

$ # after adding the explicit overload
$ grep -c 'logMessage' shared.framework/Headers/shared.h
2
- (void)logMessage:(NSString *)message;
- (void)logMessage:(NSString *)message level:(SharedLevel *)level;

That the nullable workaround diverged in production. The two weeks, measured from log volume rather than from code review:

  target      resolved default   log lines / session
  android     Level.Info                  41
  ios         Level.Info                  39
  desktop     Level.Info                  44
  jvm         Level.Info                  40
  web         Level.Debug              1,847      ← the alert

Five actuals, one of them resolving the "default" differently, and nothing comparing them. The alert was a cost ceiling on the log backend.

That the overload must be implemented on every target. The error when it is not, which is the cost of choosing an overload over a default:

$ ./gradlew :core:log:compileKotlinIosArm64
e: Expected class 'Logger' has no actual declaration in module <app> for Native (iosArm64):
   fun log(message: String)

A loud, locatable error — which is why the overload is worth it where Swift matters and not everywhere.

That the rule applies to constructors too, which is where it next bit us:

expect class Cache(maxEntries: Int = 100)
actual class Cache actual constructor(maxEntries: Int = 100)   // same error

Same message, same wrong-looking location, and the fix is the same — the default belongs to the expect's constructor and the actual's takes it without one. Knowing the rule once covers both.

What I cannot tell you is whether the diagnostic has improved in a newer Kotlin. It is the message on the version we are on, and the location is arguably correct from the compiler's point of view — it is reporting where it found the surplus value. The confusion is a property of reading the message as a claim about the actual rather than about the pair.

Three measurements: the exported header gaining a selector only when an explicit overload is added, the nullable workaround producing a different resolved default on one target for two weeks, and the same rule biting on a constructor

What this generalizes to

When one declaration is split across two files, each half owns a different part of it, and a diagnostic can only report where it detected an inconsistency rather than where the author's mistake was. The useful habit is to read such an error as being about the pair and to check both halves before editing either — and to learn the ownership split once, because it covers functions, constructors and properties with the same rule. Here the split is: the expect owns what callers see, the actual owns what happens.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS