Your default arguments do not exist in Swift
Swift sees the full parameter list, every time.
TL;DR
- A
fun search(query: String, limit: Int = 20, includeArchived: Boolean = false)that Kotlin callers used assearch("kotlin")reached Swift as a three-argument function with no defaults, and 31 Swift call sites had to pass all three. - Objective-C has no default arguments. The exporter emits one method with the full parameter list, so the defaults live only in the Kotlin compiler and never cross.
- Worse than verbose: when the default changed from 20 to 50, every Kotlin caller picked it up and all 31 Swift call sites kept passing 20. The two platforms diverged silently for five weeks.
- What reading on buys: why
@JvmOverloadsdoes not help, the explicit-overload shape that does, and the one assertion that makes a default change break the Swift build.
The case this post is about
A search use case with the defaults Kotlin makes natural:
// commonMain/kotlin/search/SearchUseCase.kt
class SearchUseCase(private val repo: SearchRepo) {
suspend fun search(
query: String,
limit: Int = 20,
includeArchived: Boolean = false,
sort: SortOrder = SortOrder.Relevance,
): List<Hit> = repo.search(query, limit, includeArchived, sort)
}
Kotlin callers, across Android and shared code, almost all use one argument:
val hits = search("kotlin") // 24 of 27 call sites
val recent = search("kotlin", sort = SortOrder.Recent) // 3 of 27
And the generated Obj-C header:
@interface SharedSearchUseCase : SharedBase
- (void)searchQuery:(NSString *)query
limit:(int32_t)limit
includeArchived:(BOOL)includeArchived
sort:(SharedSortOrder *)sort
completionHandler:(void (^)(NSArray<SharedHit *> * _Nullable, NSError * _Nullable))completionHandler;
@end
One method. Four required parameters. No overload taking fewer.
The symptom
The immediate symptom is verbosity, which is annoying and survivable:
// every Swift call site, 31 of them
let hits = try await useCase.search(
query: "kotlin",
limit: 20, // ← copied from the Kotlin default, by hand
includeArchived: false, // ← and this
sort: SortOrder.relevance // ← and this
)
The real symptom arrived five weeks later. Product asked for a longer first page, and the change was one character:
- limit: Int = 20,
+ limit: Int = 50,
Every Kotlin caller picked it up. All 31 Swift call sites kept passing 20, because 20 was a
literal in their source — and nothing failed, because passing an explicit value is exactly what
the API asks for.
# first-page size, by platform, after the "change"
android 50
web 50
desktop 50
ios 20 ← for five weeks
Found by an analyst noticing that iOS users scrolled to a second page far more often.

Why the obvious fix failed
The first instinct was @JvmOverloads, which solves exactly this problem for Java callers:
@JvmOverloads
suspend fun search(query: String, limit: Int = 20, …): List<Hit>
w: '@JvmOverloads' annotation has no effect for non-JVM targets
It is a JVM-only annotation and the warning says so. There is no @ObjCOverloads, and the reason
is not an oversight — see below.
The second attempt was to give the Swift side its own extension with defaults:
// iosApp — a Swift-side convenience
extension SearchUseCase {
func search(query: String) async throws -> [Hit] {
try await search(query: query, limit: 20, includeArchived: false, sort: .relevance)
}
}
Which fixes the verbosity and makes the divergence worse: the default now lives in a second
language, in a different repository, maintained by a different team. The 20 has gone from 31
places to one place, and that one place is still not the place the Kotlin default is.
The third attempt was a parameter object, which is a reasonable design and did not survive contact with the boundary:
data class SearchParams(
val query: String,
val limit: Int = 20,
val includeArchived: Boolean = false,
)
suspend fun search(params: SearchParams): List<Hit>
A data class with defaults exports as a class whose constructor has the same problem — Swift
sees SearchParams(query:limit:includeArchived:) with all three required. The defaults moved and
did not cross. I had relocated the problem by one type.
The actual mechanism
Default arguments are a call-site transformation performed by the Kotlin compiler.
There is no default in the compiled function. search("kotlin") compiles to a call passing
20, false and SortOrder.Relevance explicitly — or, for larger parameter lists, to a call to a
synthetic $default bridge taking a bitmask of which arguments were supplied. The defaults are
baked into the caller, not stored on the callee.
Obj-C has no mechanism to express them. A method is a selector with a fixed argument list. There is no syntax for "this parameter may be omitted", so there is nothing for the exporter to emit even if it wanted to.
And the $default bridge is not exported. It is a synthetic JVM/Native-internal function, so
Swift cannot call the thing that knows the defaults either.
Which explains why @JvmOverloads has no cross-platform equivalent: on the JVM it generates real
extra methods, one per prefix of the parameter list. Obj-C could in principle receive the same
treatment, and the reason it does not is that the overloads would all map to the same selector
family with different argument counts — searchQuery:, searchQuery:limit: — which is legal, so
this is a feature gap rather than an impossibility. It does not exist today, so the overloads have
to be written by hand.
The silent-divergence property is the one worth internalising. A default that does not cross
produces code on the other side that hardcodes the default's current value. That code keeps
compiling and keeps working when the default changes, which means the platforms drift and nothing
observable happens. It is not a build error, not a runtime error, and not a difference anyone
reviews — the Swift source says limit: 20 and always did.

The fix
Write the overloads explicitly, in Kotlin, so they export as real methods — and put the default value in exactly one place that both the overload and the Kotlin signature read from.
// commonMain/kotlin/search/SearchUseCase.kt
class SearchUseCase(private val repo: SearchRepo) {
/**
* The defaults, as named constants. ONE place, read by both the Kotlin default expression
* and the exported overloads below — so a change here reaches every caller on every
* platform, which is the property the language-level default silently did not have.
*/
object Defaults {
const val LIMIT = 50
const val INCLUDE_ARCHIVED = false
val SORT = SortOrder.Relevance
}
/** The full form. Kotlin callers may still omit arguments; Swift sees all four. */
suspend fun search(
query: String,
limit: Int = Defaults.LIMIT,
includeArchived: Boolean = Defaults.INCLUDE_ARCHIVED,
sort: SortOrder = Defaults.SORT,
): List<Hit> = repo.search(query, limit, includeArchived, sort)
/**
* The exported convenience. A REAL function, so it becomes a real Obj-C selector that Swift
* can call with one argument — and it reads Defaults, so it cannot drift from the full form.
*
* Two overloads, not every combination: with three optional parameters there are 2^3 = 8
* ways to call this and most are not useful. These are the shapes Swift call sites
* actually wanted, measured — 24 of 27 Kotlin call sites pass only the query.
*/
suspend fun search(query: String): List<Hit> =
search(query, Defaults.LIMIT, Defaults.INCLUDE_ARCHIVED, Defaults.SORT)
/** The one other combination in real use: a query and a sort. */
suspend fun search(query: String, sort: SortOrder): List<Hit> =
search(query, Defaults.LIMIT, Defaults.INCLUDE_ARCHIVED, sort)
}
And the assertion that makes a default change break the Swift build — which is the part that addresses the five weeks rather than the verbosity:
// shared/src/commonTest/kotlin/search/ExportedDefaultsTest.kt
//
// Asserts that the exported overload passes the values in `Defaults` and not literals of
// its own. The first version of this test compared `search("kotlin")` against
// `search(query = "kotlin")` — which are the SAME overload, because Kotlin prefers a
// candidate needing no default arguments — so it compared a function with itself and would
// have passed no matter how far the overload drifted. Asserting against Defaults is what
// makes the drift detectable.
class ExportedDefaultsTest {
@Test
fun theExportedOverloadReadsTheDeclaredDefaults() = runTest {
val repo = RecordingSearchRepo()
val useCase = SearchUseCase(repo)
useCase.search("kotlin") // the one-argument EXPORTED overload
val call = repo.lastCall!!
assertEquals(SearchUseCase.Defaults.LIMIT, call.limit,
"the exported overload passes a limit that is not Defaults.LIMIT — Swift callers " +
"would be getting different behaviour from Kotlin callers")
assertEquals(SearchUseCase.Defaults.INCLUDE_ARCHIVED, call.includeArchived)
assertEquals(SearchUseCase.Defaults.SORT, call.sort)
}
}
// iosApp — every call site, after
let hits = try await useCase.search(query: "kotlin")
And the output it produced, re-running the limit change end to end:
$ # change Defaults.LIMIT from 50 to 30, touch nothing else
$ ./gradlew :shared:allTests && ./gradlew :shared:linkDebugFrameworkIosArm64
$ xcodebuild -scheme iosApp build
BUILD SUCCEEDED
$ ./tools/first-page-size-probe.sh --platforms all
android 30
web 30
desktop 30
ios 30 ← one change, four platforms
How we proved it
That the exported method has no defaults — read from the generated header rather than inferred:
$ grep -A5 'searchQuery' shared.framework/Headers/shared.h
- (void)searchQuery:(NSString *)query
limit:(int32_t)limit
includeArchived:(BOOL)includeArchived
sort:(SharedSortOrder *)sort
completionHandler:…
# one method. No searchQuery: alone, no searchQuery:limit:.
That the explicit overloads do export, which is the whole bet:
$ grep -c '^- (void)searchQuery' shared.framework/Headers/shared.h
3
$ grep '^- (void)searchQuery' shared.framework/Headers/shared.h
- (void)searchQuery:(NSString *)query completionHandler:…
- (void)searchQuery:(NSString *)query sort:(SharedSortOrder *)sort completionHandler:…
- (void)searchQuery:(NSString *)query limit:(int32_t)limit includeArchived:(BOOL)… completionHandler:…
Three selectors for three real Kotlin functions.
That the divergence was silent and is now not. The test is the change itself, run both ways:
android ios ios build
language default 20 → 50 50 20 SUCCEEDED ← the five weeks
Defaults.LIMIT 50 → 30, overloads 30 30 SUCCEEDED
Defaults.LIMIT 50 → 30, overload passing
a literal 50 instead (the fixture) 30 50 TEST FAILED
The third row is the one that matters: the drift is now a failing Kotlin test rather than an analytics anomaly five weeks later.
That a parameter object does not help. Checked, because it is the design most people reach for:
$ grep -A4 'interface SharedSearchParams' shared.h
@interface SharedSearchParams : SharedBase
- (instancetype)initWithQuery:(NSString *)query
limit:(int32_t)limit
includeArchived:(BOOL)includeArchived;
The constructor has the identical problem. Moving defaults into a type moves them to another place that cannot export them.
That two overloads are enough, measured rather than assumed. The temptation is one method per combination — with three optional parameters, 2³ = 8:
Kotlin call sites by argument shape
query only 24 / 27
query + sort 3 / 27
query + limit 0 / 27
anything with includeArchived 0 / 27
Two overloads cover 27 of 27. The other six would be exported API nobody calls, and exported API has a size cost on iOS.
What I cannot tell you is whether this holds as the parameter list grows. Four parameters and two real call shapes made the overloads obvious; a function with eight parameters and a long tail of combinations would need a builder, and a builder has its own export problems I have not measured.
And an honest cost. Defaults is public, so it is exported too — Swift can read
SearchUseCase.Defaults.LIMIT, which is mildly odd and is the price of having one authority for
the value. Making it internal would hide it from Swift and from the test's own reasoning about
what Swift sees, and I preferred the oddness.

What this generalizes to
A language feature implemented at the call site does not survive a boundary that only transports declarations, and the failure is not the inconvenience — it is that the other side ends up hardcoding whatever the value was on the day it was written. That code keeps working, so the divergence has no symptom until someone compares the platforms. The general check is to ask, of any convenience your language provides, whether it is a property of the callee or a rewrite of the caller: the first crosses, the second leaves a literal behind on the far side.
Related
- A sealed hierarchy exports as an open class — the same erasure applied to exhaustiveness, where the loss is a compile-time guarantee.
- Two Kotlin classes, one Objective-C name — the other way the export surface differs from what you wrote.
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".