Engineering

Two Kotlin classes, one Objective-C name, and a header that will not compile

Objective-C has no namespaces, so two same-named classes export as one.

MobileByteSensei 10 min read
kmpiosinterop

TL;DR

  • Adding a feature.search.Query to a module that already had feature.filter.Query broke the iOS build with duplicate interface definition for class 'SharedQuery' — in a generated header, from two files that had never been compiled together before.
  • Objective-C has no namespaces. The exporter flattens every package to one global namespace, prefixes with the framework's baseName, and two same-named classes produce one declaration.
  • The fix is @ObjCName, and it is a permanent public API decision on the Swift side rather than a build-file tweak — so the rename belongs with the type, chosen once, not applied in a panic.
  • What reading on buys: what the flattening actually does, why baseName does not help, and the audit that finds the next collision before a release does.

The case this post is about

Two feature modules, each with its own Query type, which is exactly what packages are for:

// feature/search/src/commonMain/kotlin/Query.kt
package com.example.feature.search
data class Query(val text: String, val limit: Int)
// feature/filter/src/commonMain/kotlin/Query.kt
package com.example.feature.filter
data class Query(val facets: List<Facet>, val negated: Boolean)

Both compiled, both used from Kotlin without ambiguity, both in the shared framework's export set:

// shared/build.gradle.kts
framework {
    baseName = "Shared"
    export(projects.feature.search)
    export(projects.feature.filter)
}

The symptom

$ ./gradlew :shared:linkDebugFrameworkIosArm64

e: Compilation failed: duplicate interface definition for class 'SharedQuery'
   shared.framework/Headers/shared.h:4182:12: note: previous definition is here
   shared.framework/Headers/shared.h:9044:12: error: duplicate interface definition

> Task :shared:linkDebugFrameworkIosArm64 FAILED

A clang error in a header nobody wrote, pointing at two line numbers in a 12,000-line generated file. Not a Kotlin error, so no Kotlin file is named — and the two Query classes are in different modules that a reader has no reason to connect.

The Android build was green. The JVM and wasm builds were green. The failure is specific to the framework link, which on a PR running ciCompile did not run at all — so it landed on main and was found by the nightly.

A clang duplicate-definition error in a twelve-thousand-line generated header naming two line numbers and no Kotlin source, from two modules that had each compiled cleanly in isolation

Why the obvious fix failed

The first attempt was baseName, on the theory that the prefix is the namespacing mechanism:

framework { baseName = "SharedFeatures" }
e: duplicate interface definition for class 'SharedFeaturesQuery'

baseName prefixes the framework, once, for everything in it. It disambiguates your framework from another framework's Query and does nothing within one framework.

The second attempt was to stop exporting one of the modules:

export(projects.feature.search)
// export(projects.feature.filter)     ← implementation() instead

Which fixed the link and removed filter.Query from Swift — and Swift needed it, because the filter UI is native. It is a real option when a type genuinely is not consumed from Swift, and it was not the case here.

The third attempt was to rename the Kotlin class:

package com.example.feature.filter
data class FilterQuery(val facets: List<Facet>, val negated: Boolean)

That works, and it makes every Kotlin call site read FilterQuery inside a package already called filter — filter.FilterQuery. Stuttering, and it lets the export boundary dictate the shared code's naming, which is the tail wagging the dog for a problem that only exists in one of five targets.

The actual mechanism

The Obj-C exporter has to produce a single flat namespace, because that is all Obj-C has.

Every exported declaration gets one global name. com.example.feature.search.Query and com.example.feature.filter.Query both become Query, prefixed with baseName — so SharedQuery, twice.

The prefix is per framework, not per package. There is no mechanism to derive a prefix from the package, and there is a reason: a package-derived prefix would make every rename of a Kotlin package a breaking change to the Swift API. So the exporter leaves naming to you.

The collision surfaces at link time, not at compile time, because the header is generated when the framework is assembled. Each module compiled fine in isolation; the conflict exists only in the union.

And objc_subclassing_restricted and the rest of the attributes do not interact with it — this is purely a name clash in a C-family header, and clang's error is accurate and unhelpful about cause.

The consequence worth internalising: your Kotlin package structure is invisible to Swift, so the flat name is the API. Two types with the same simple name are the same type as far as the export is concerned, and that is true of enums, interfaces, objects and nested classes too — a nested Companion in two exported classes is fine because it is scoped, and a top-level Error in two packages is not.

The exporter flattening every Kotlin package into one global namespace with a per-framework prefix, so two identically-named types in different packages claim the same exported name

The fix

@ObjCName on the type, chosen deliberately, because the name it produces is permanent Swift API.

// feature/filter/src/commonMain/kotlin/Query.kt
package com.example.feature.filter

/**
 * `@ObjCName` renames this type for Obj-C and Swift consumers ONLY. Kotlin call sites keep
 * `filter.Query`, which is the name the package structure already makes unambiguous.
 *
 * The exact name is a PERMANENT public API decision for the Swift side — renaming it later is
 * a breaking change for every Swift call site, exactly like renaming a Kotlin class is for
 * Kotlin ones. So it is chosen here, with the type, rather than applied as a build fix.
 *
 * `exact = true` means the name is used VERBATIM, without the framework's baseName prefix.
 * Without it the export is `SharedFilterQuery`; with it, `FilterQuery`. We use exact = false
 * so the prefix is kept and the type is unmistakably ours in a Swift file that imports
 * several frameworks.
 */
@OptIn(ExperimentalObjCName::class)
@ObjCName("FilterQuery")
data class Query(val facets: List<Facet>, val negated: Boolean)
// feature/search/src/commonMain/kotlin/Query.kt
package com.example.feature.search

// The search Query keeps the unprefixed export, because it is the one Swift uses most and
// `SharedQuery` reads correctly there. Renaming BOTH would be tidier and would churn 60
// Swift call sites for symmetry, which is not a reason.
data class Query(val text: String, val limit: Int)

And the audit, because the next collision is a matter of time and currently arrives as a clang error on main:

// build-logic/src/main/kotlin/ObjCNameCollisionCheck.kt
//
// Computes the exported Obj-C name for every exported declaration and fails on a duplicate —
// at CONFIGURATION, before the link, with the two Kotlin files named. The clang error names
// two line numbers in a 12,000-line generated header and no Kotlin source at all.
abstract class CheckObjCNameCollisions : DefaultTask() {

    @get:InputFiles abstract val exportedSources: ConfigurableFileCollection

    @TaskAction
    fun check() {
        // simple name → the declarations that will claim it
        val claims = mutableMapOf<String, MutableList<String>>()

        exportedSources.files.filter { it.extension == "kt" }.forEach { file ->
            val text = file.readText()
            val pkg = Regex("^package\\s+([\\w.]+)", RegexOption.MULTILINE)
                .find(text)?.groupValues?.get(1) ?: ""

            // Top-level PUBLIC declarations only. An internal or private type is not exported,
            // and counting it would produce failures for collisions that cannot occur — which
            // is how a check gets switched off.
            Regex("""^(?!.*\b(internal|private)\b)[^\n]*?\b(class|interface|object|enum class)\s+(\w+)""",
                  RegexOption.MULTILINE)
                .findAll(text).forEach { m ->
                    val simple = m.groupValues[3]

                    // An @ObjCName on the declaration overrides the simple name, so the audit
                    // must read it — otherwise every FIXED collision reports as a collision
                    // and the check is noise within a week.
                    val objcName = Regex("""@ObjCName\s*\(\s*"(\w+)"""")
                        .findAll(text.substring(0, m.range.first))
                        .lastOrNull()?.groupValues?.get(1)

                    claims.getOrPut(objcName ?: simple) { mutableListOf() } += "$pkg.$simple"
                }
        }

        val collisions = claims.filterValues { it.size > 1 }
        if (collisions.isNotEmpty()) {
            error(buildString {
                appendLine("exported Obj-C name collision(s) — the framework link will fail:")
                collisions.forEach { (name, decls) ->
                    appendLine("  '$name' claimed by:")
                    decls.forEach { appendLine("      $it") }
                }
                appendLine()
                appendLine("Add @ObjCName(\"<DistinctName>\") to one of them. The name is PERMANENT")
                appendLine("Swift API — choose it with the type, not as a build fix.")
            })
        }
    }
}

And the output it produced, run against the broken state:

$ ./gradlew :shared:checkObjCNameCollisions

FAILURE: exported Obj-C name collision(s) — the framework link will fail:
  'Query' claimed by:
      com.example.feature.search.Query
      com.example.feature.filter.Query

Add @ObjCName("<DistinctName>") to one of them. The name is PERMANENT
Swift API — choose it with the type, not as a build fix.

Two Kotlin files named, at configuration, in 1.2 seconds — against a clang error after a two-and-a-half-minute link.

How we proved it

That baseName prefixes the framework and not the package — tested, because it is the first thing everyone tries:

  baseName            exported names
  "Shared"            SharedQuery, SharedQuery        ← collision
  "SharedFeatures"    SharedFeaturesQuery, …Query     ← collision, longer

That the collision is link-time and invisible to every other target. The measurement that explains how it reached main:

  task                                      result
  :feature:search:compileKotlinIosArm64     SUCCESS
  :feature:filter:compileKotlinIosArm64     SUCCESS
  :shared:compileKotlinIosArm64             SUCCESS
  :shared:linkDebugFrameworkIosArm64        FAILED      ← only here
  :shared:compileKotlinAndroid              SUCCESS
  :shared:compileKotlinWasmJs               SUCCESS

Our PR pipeline runs compile tasks and not link tasks, deliberately, for build-time reasons — so this class of failure is structurally invisible on a PR. That is a trade we made knowingly and this is the cost of it, which is why the configuration-time audit matters more here than it would otherwise.

That the audit reads @ObjCName and therefore does not re-report fixed collisions. The fixture is the fixed state, which a naive audit would still flag:

$ ./gradlew :shared:checkObjCNameCollisions          # after the @ObjCName
BUILD SUCCESSFUL
$ # and with the annotation-reading removed from the audit
FAILURE: 'Query' claimed by: …search.Query, …filter.Query      ← a false failure

That the collision class is wider than top-level classes. Found by running the audit over the whole export set rather than only the two modules:

  'Error'        claimed by  core.network.Error, core.storage.Error        ← would collide
  'Config'       claimed by  core.remote.Config, feature.debug.Config      ← would collide
  'State'        claimed by  3 declarations                                ← would collide
  'Companion'    claimed by  41 declarations                               ← does NOT collide

Three latent collisions nobody had hit because those modules were not all exported yet, and Companion is the control — nested names are scoped, so 41 of them are fine, and an audit that flagged them would have been deleted immediately.

That exact = true drops the prefix, which we decided against and is worth recording:

  @ObjCName("FilterQuery")                 → SharedFilterQuery
  @ObjCName("FilterQuery", exact = true)   → FilterQuery

The unprefixed form reads better in isolation and loses the signal that the type came from our framework, which matters in a Swift file importing three.

What I cannot tell you is whether the regex audit is complete. It parses Kotlin with a regular expression, which is wrong in general — a type declared inside a multiline string, or an unusual modifier order, can be missed. It found the three latent collisions and the known one; a proper implementation would read the generated header after a link, which is accurate and costs the two-and-a-half minutes the audit exists to avoid. We run both: the regex at configuration on every build, and a header-based check in the nightly link.

And an honest consequence. @ObjCName means the Kotlin name and the Swift name differ, so a Swift developer reading our Kotlin sees Query and writes SharedFilterQuery. That is a real cognitive cost, documented in the module's README, and the alternative — renaming the Kotlin class — pushes the cost onto every Kotlin call site instead. Neither is free; we put it on the smaller population.

Three findings from running the audit over the whole export set: the collision is visible only at the link step, three further latent collisions nobody had hit, and forty-one nested companion names that correctly do not collide

What this generalizes to

A boundary that flattens a namespace makes your simple names the API, and the structure you used to keep them apart stops existing on the other side. The failure arrives from the union rather than from either part, so it is invisible to anything that builds the parts separately — which means the check has to run over the whole export set and has to run earlier than the step that assembles it. And when the fix is a name, remember that the name is published: choose it with the type, in the file, rather than reaching for whatever unblocks the build.


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