Our iOS build broke after an Xcode update, and it greeted me with the least helpful error message I've read in a long time:
Cannot convert return expression of type 'MultiplatformLoginViewModel.State'
to return type 'MultiplatformLoginViewModel.State'
Same type. Twice. Convert it to itself, apparently.
Here's what was actually going on, and the two ways out.
The setup
We run a Kotlin Multiplatform project: business, integration and presentation logic all shared. The screen ViewModels are shared too, while the UI stays native — Compose on Android, SwiftUI on iOS. Each ViewModel carries its own nested state class, named State:
class LoginViewModel : ViewModel() {
data class State(
val email: String = "",
val password: String = "",
)
// ...
}
This reads nicely on the Kotlin side. LoginViewModel.State, ProfileViewModel.State, CartViewModel.State — the name says what it is, the enclosing class says whose it is. Works just fine on Android.
Export the framework to Swift and those become MultiplatformLoginViewModel.State and friends. Also fine. Until it wasn't.
When it started
Everything was green through Xcode 26.3. The first build on Xcode 26.4 lit up red.
Xcode 26.4 was the first release to ship Swift 6.3. This isn't really "an Xcode 26.4 bug", it's the Swift 6.2 → 6.3 jump that 26.4 happened to deliver.
Why it happens
Two ingredients, and you need both.
The first is SWIFT_ENABLE_BATCH_MODE, which is on by default. It's a build-time optimization: rather than handing the compiler one file at a time, Xcode groups several of your Swift files and compiles them together. Faster builds, and normally completely invisible to you.
The second is SwiftUI's @State.
Put them together and you get a name clash. In a file that uses @State, the name State already means something — SwiftUI's property wrapper. In a file that returns your Kotlin LoginViewModel.State, it means something else entirely. On their own, each file is unambiguous. Batched together, the two States get crossed, and your state class ends up being checked against a State that isn't the one you meant. Both get printed under the same name, which is why the error reads like nonsense.
I won't pretend I chased this all the way down into the compiler. The short version: same name, two meanings, one compilation batch.
That batching is also what makes the bug so annoying to identify. Which files end up grouped together is Xcode's call, not yours, and it shifts as your project changes. The error can land on a file you haven't touched in months. It shows up after an unrelated commit, points at a different ViewModel than the one you were working on. In the same way, it may disappear on a clean build and come back an hour later.
Quick Fix: Unblock the build
We needed the build green again before anyone could think about a tidier solution. Two changes did it, neither of them touching Kotlin.
First, we turned SWIFT_ENABLE_BATCH_MODE off. That cost us less than it might sound — only our SwiftUI screens are written in Swift, so there wasn't much left to batch anyway.
Second, in every Swift file that used @State, we gave the Kotlin type a name of its own with a typealias:
typealias LoginState = LoginViewModel.State
struct LoginScreen: View {
@State private var passwordVisible = false
// was: -> LoginViewModel.State
private func currentState() -> LoginState {
viewModel.state
}
}
Neither half was enough on its own — we tried both. Your project won't be set up like ours, so take this as what unblocked us rather than a recipe. But if any of it looks familiar, it's the cheapest thing to try before touching shared code.
Clean Fix: Proper naming for the win
Rename the Kotlin State class. You may rename it to whatever you want, or even extract it to a standalone class so LoginViewModel.State ends up as just LoginState. We've just renamed the State class to another well established term, UiState:
class LoginViewModel : ViewModel() {
data class UiState(
val email: String = "",
val password: String = "",
)
}
It's a one-to-one replacement, it's mechanical, and your IDE does nearly all of it for you. Now nothing on the Swift side is called State except SwiftUI's own, the clash is gone, and SWIFT_ENABLE_BATCH_MODE can stay at its default (which may pay off in the future). No typealiases to maintain, no build-speed tax, nothing for the next person to trip over.
On Android, nothing changes but the name.
The takeaway
The lesson to take: when you share Kotlin code with SwiftUI, your type names land in somebody else's neighbourhood. State, Environment, Binding, Task, Group — all perfectly ordinary words in Kotlin, all loaded terms in SwiftUI. Nesting one inside a ViewModel feels like enough namespacing, and on Android it is. On the Swift side you're one build optimization away from an error telling you a type can't be converted to itself.
So pick names that don't collide. UiState costs you just two characters.
Top comments (0)