An expect declaration cannot carry default arguments, and the error says otherwise
Defaults live on the expect side; the diagnostic points at the actual.
TL;DR
- Moving a default argument from an
actualto anexpectproduced 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
expectand may not appear on theactual. 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.

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

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.

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.
Related
- actual typealias looks like a shortcut — the other way the two halves disagree about what was published.
- Your default arguments do not exist in Swift — the half of this that the compile fix does not address.
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".