KMP/Swift Interop Patterns
This document captures architectural decisions about when to use Kotlin Multiplatform (KMP) shared code in iOS versus maintaining native Swift implementations. These patterns emerged from PR #419 (Username Validation) and are applied throughout the codebase.
The Core Trade-off
KMP enables code sharing between Android and iOS, but some Kotlin constructs don’t translate well to idiomatic Swift. The key insight:
Keep Swift for sealed classes with complex hierarchies. Use KMP for data models, enums, and pure functions.
When to Use KMP Shared Code
✅ Data Models (Simple Data Classes)
KMP data classes translate cleanly to Swift:
// KMP (shared)
@Serializable
data class LeaderboardSettings(
val userId: String = "",
val defaultBowType: BowType? = null,
val showOwnScores: Boolean = true
)// iOS usage - clean and idiomatic
let settings = LeaderboardSettings(
userId: "abc123",
defaultBowType: .recurve,
showOwnScores: true
)✅ Enums (Using .name Comparison)
Simple enums work well when compared by name:
// KMP (shared)
enum class BowType {
RECURVE, COMPOUND, BAREBOW, TRADITIONAL
}// iOS usage
if bowType.name == "RECURVE" { ... }
// Or with extension:
extension BowType {
var isRecurve: Bool { self.name == "RECURVE" }
}✅ Pure Functions Without Complex Return Types
Utility functions with primitive returns share well:
// KMP (shared)
object TimeKeyGenerator {
fun generateSeasonKey(timestampMillis: Long): String
fun currentSeasonKey(): String
}// iOS usage
let seasonKey = TimeKeyGenerator.shared.currentSeasonKey()✅ Constants and Configuration Values
Shared constants ensure consistency:
// KMP (shared)
object ValidationConstants {
const val MAX_USERNAME_LENGTH = 24
const val MIN_USERNAME_LENGTH = 1
}When to Keep Native Swift
❌ Sealed Classes with Complex Hierarchies
This is the primary case for native Swift. KMP sealed classes translate to verbose, non-idiomatic Swift.
Example: UsernameValidator
KMP (Kotlin):
sealed class ValidationResult {
data object Valid : ValidationResult()
data class Invalid(val reason: String) : ValidationResult()
}
// Usage in Kotlin - clean
when (result) {
is ValidationResult.Valid -> handleValid()
is ValidationResult.Invalid -> showError(result.reason)
}Generated Swift (hypothetical):
// KMP generates class hierarchy with verbose names
class UsernameValidatorValidationResult {}
class UsernameValidatorValidationResultValid: UsernameValidatorValidationResult {}
class UsernameValidatorValidationResultInvalid: UsernameValidatorValidationResult {
let reason: String
}
// Usage - verbose and unidiomatic
if result is UsernameValidatorValidationResultValid {
handleValid()
} else if let invalid = result as? UsernameValidatorValidationResultInvalid {
showError(invalid.reason)
}Native Swift (actual implementation):
enum ValidationResult {
case valid
case invalid(reason: String)
}
// Usage - clean, idiomatic Swift
switch result {
case .valid:
handleValid()
case .invalid(let reason):
showError(reason)
}Decision: Keep UsernameValidator.swift with native Swift enum because case .valid is much cleaner than is UsernameValidatorValidationResultValid.
❌ Types Used Extensively in SwiftUI
SwiftUI pattern matching works best with native Swift enums:
// Native Swift - works great with SwiftUI
enum AdminPanelState {
case loading
case accessDenied
case ready
case error(String)
}
// SwiftUI usage
Group {
switch viewModel.state {
case .loading:
ProgressView()
case .accessDenied:
AccessDeniedView()
case .ready:
AdminDashboardView()
case .error(let message):
ErrorView(message: message)
}
}❌ Types Where Swift-Native API Provides Better DX
Some Swift patterns don’t exist in Kotlin:
// Native Swift error handling with LocalizedError
enum AuthBridgeError: LocalizedError {
case noUserSignedIn
case requiresReauthentication
case credentialAlreadyInUse
var errorDescription: String? {
switch self {
case .noUserSignedIn:
return "No user is currently signed in"
case .requiresReauthentication:
return "This operation requires recent sign-in"
case .credentialAlreadyInUse:
return "This account is already linked to another user"
}
}
}Real-World Examples
FirebaseAuthRepositoryBridge.swift
Pattern: Native Swift implementation conforming to KMP protocol.
Why native Swift?
- KMP
AuthResult<T>sealed class doesn’t work well with Swift generics - Swift async/await doesn’t play nicely with ObjCExportCoroutines
- Native
AuthBridgeErrorenum provides better error handling
/// Swift-native implementation of AuthRepository for iOS.
/// Uses Firebase Auth SDK directly rather than going through KMP bridge
/// to avoid ObjCExportCoroutines issues with async/await.
///
/// Note: This method throws errors rather than returning AuthResult.Failure
/// because Swift doesn't support KMP's covariant sealed class generics properly.
class FirebaseAuthRepositoryBridge: AuthRepository {
func signInWithGoogle() async throws -> AuthResult<AuthUser> {
// Native Firebase SDK calls
}
}UsernameValidator.swift
Pattern: Parallel native implementation with identical logic.
Why native Swift?
ValidationResultsealed class would be verbose in Swift- SwiftUI views consume validation results extensively
- Maintenance burden is low (simple validation logic)
enum UsernameValidator {
enum ValidationResult {
case valid
case invalid(reason: String)
}
static func validate(_ username: String) -> ValidationResult {
// Same logic as KMP, native Swift types
}
}AdminPanelViewModel.swift
Pattern: Native ViewModel with direct Firestore access.
Why native Swift?
AdminPanelStateenum used for SwiftUI view state- Direct Firestore SDK provides better iOS integration
- No KMP Flow-to-Swift bridging complexity
Decision Framework
When adding new shared functionality, ask:
| Question | If Yes → | If No → |
|---|---|---|
| Is it a simple data class? | Use KMP | Consider native |
| Does it return a sealed class? | Keep native Swift | May use KMP |
| Is it used extensively in SwiftUI? | Keep native Swift | May use KMP |
| Is the logic simple/stable? | Parallel impl OK | Prefer single source |
| Does it involve async/await? | Consider native bridge | May use KMP |
Maintenance Considerations
Parallel Implementations
When maintaining parallel Swift/Kotlin implementations:
- Document the duplication - Add comments explaining why
- Keep logic identical - Same validation rules, same constants
- Test both - Unit tests in both languages
- Review together - Changes to one should prompt review of other
Shared Constants
Even with parallel implementations, share constants:
// KMP shared - use in both platforms
object UsernameConstants {
const val MAX_LENGTH = 24
const val MIN_LENGTH = 1
}// iOS - reference shared constants
let maxLength = UsernameConstants.shared.MAX_LENGTHAnti-Patterns
❌ Forcing KMP for Everything
Don’t force KMP when Swift is cleaner:
// BAD: Using verbose KMP sealed class
if result is UsernameValidatorValidationResultValid { ... }
// GOOD: Using native Swift enum
if case .valid = result { ... }❌ Duplicating Without Documentation
Don’t create parallel implementations without explaining why:
// BAD: No explanation
enum ValidationResult { ... }
// GOOD: Documented reasoning
/// Native Swift validation result for idiomatic SwiftUI integration.
/// See kmp-swift-interop-patterns.md for rationale on keeping this
/// separate from KMP UsernameValidator.
enum ValidationResult { ... }Related Documentation
- 2025-12-30-phase5-admin-system - AdminPanelViewModel uses native Swift patterns
- global-admin-system - iOS architecture notes