Engineering

Your default arguments do not exist in Swift

Swift sees the full parameter list, every time.

MobileByteSensei 10 min read
kmpiosinterop

TL;DR

  • A fun search(query: String, limit: Int = 20, includeArchived: Boolean = false) that Kotlin callers used as search("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 @JvmOverloads does 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.

A default limit changed from twenty to fifty reaching every Kotlin caller while all thirty-one Swift call sites continued passing twenty, because the value was a literal in their source and passing it explicitly is what the exported API asks for

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.

Default arguments as a call-site transformation performed by the Kotlin compiler, with the value baked into the caller rather than stored on the callee, and the synthetic bridge that knows them not exported

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.

Three measurements from the generated header and the call-site survey, showing one exported method before and three after, a parameter object relocating the problem to a constructor, and two overloads covering every real call shape

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.


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