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 AuthBridgeError enum 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?

  • ValidationResult sealed 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?

  • AdminPanelState enum 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:

QuestionIf Yes →If No →
Is it a simple data class?Use KMPConsider native
Does it return a sealed class?Keep native SwiftMay use KMP
Is it used extensively in SwiftUI?Keep native SwiftMay use KMP
Is the logic simple/stable?Parallel impl OKPrefer single source
Does it involve async/await?Consider native bridgeMay use KMP

Maintenance Considerations

Parallel Implementations

When maintaining parallel Swift/Kotlin implementations:

  1. Document the duplication - Add comments explaining why
  2. Keep logic identical - Same validation rules, same constants
  3. Test both - Unit tests in both languages
  4. 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_LENGTH

Anti-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 { ... }