Kotlin Expect/Actual
Introduction
When building multiplatform applications with Kotlin, you'll often encounter situations where you need to implement platform-specific functionality. For example, how do you handle file system access, networking, or UI components that work differently on each platform? This is where Kotlin's expect/actual mechanism comes in.
The expect/actual feature allows you to define a common API in shared code (the "expect" declaration) while providing platform-specific implementations (the "actual" declarations) for different targets like Android, iOS, desktop, and web.
Understanding Expect/Actual Declarations
The Basic Concept
Think of the expect
declaration as a contract or promise. You're saying, "I expect this function, class, or property to exist, but I'll define its implementation separately for each platform." The actual
declaration then fulfills that promise with platform-specific code.
Let's start with a simple example:
// In the common source set
expect fun getPlatformName(): String
// In the Android source set
actual fun getPlatformName(): String = "Android"
// In the iOS source set
actual fun getPlatformName(): String = "iOS"
// In the JS source set
actual fun getPlatformName(): String = "Browser"
When you call getPlatformName()
from your common code, the appropriate platform-specific implementation will be used at runtime.
Setting Up Expect/Actual in a Project
To use expect/actual declarations, your project needs to be structured with a common source set and platform-specific source sets. Here's a typical structure:
src/
├── commonMain/ // Common code with expect declarations
├── androidMain/ // Android-specific code with actual declarations
├── iosMain/ // iOS-specific code with actual declarations
├── jsMain/ // JavaScript-specific code with actual declarations
└── jvmMain/ // JVM-specific code with actual declarations
Using Expect/Actual with Different Types
Functions
We've already seen a basic function example. Let's look at a more practical one:
// In commonMain
expect fun readFile(path: String): String
// In jvmMain
import java.io.File
actual fun readFile(path: String): String {
return File(path).readText()
}
// In jsMain
import org.w3c.files.*
import kotlinx.browser.document
actual fun readFile(path: String): String {
// JavaScript implementation using the Fetch API or other browser APIs
// This is a simplified example
return fetch(path).then { it.text() }.await()
}
Properties
You can also use expect/actual with properties:
// In commonMain
expect val platform: String
// In androidMain
actual val platform: String = "Android"
// In iosMain
actual val platform: String = "iOS"
Classes
For more complex scenarios, you might need to define entire classes as expect/actual:
// In commonMain
expect class DateProvider {
fun getCurrentDate(): String
fun formatDate(timestamp: Long): String
}
// In jvmMain
import java.text.SimpleDateFormat
import java.util.Date
actual class DateProvider {
actual fun getCurrentDate(): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return sdf.format(Date())
}
actual fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return sdf.format(Date(timestamp))
}
}
// In jsMain
actual class DateProvider {
actual fun getCurrentDate(): String {
val date = js("new Date()")
return date.toISOString()
}
actual fun formatDate(timestamp: Long): String {
val date = js("new Date(timestamp)")
return date.toISOString()
}
}
Annotations
You can use annotations with expect/actual declarations to add metadata that's relevant to specific platforms:
// In commonMain
expect annotation class PlatformSpecific(val description: String)
// In jvmMain
import java.lang.annotation.Retention
import java.lang.annotation.RetentionPolicy
@Retention(RetentionPolicy.RUNTIME)
actual annotation class PlatformSpecific(actual val description: String)
// In jsMain
@JsName("PlatformSpecificJs")
actual annotation class PlatformSpecific(actual val description: String)
Real-World Examples
Example 1: Platform-Specific Networking
Let's create a simple HTTP client that works across platforms:
// In commonMain
expect class HttpClient {
suspend fun get(url: String): String
suspend fun post(url: String, body: String): String
}
// Using the HttpClient in common code
suspend fun fetchUserData(userId: String): User {
val httpClient = HttpClient()
val response = httpClient.get("https://api.example.com/users/$userId")
return parseUserJson(response) // Common function to parse JSON
}
// In jvmMain - using java.net
import java.net.HttpURLConnection
import java.net.URL
actual class HttpClient {
actual suspend fun get(url: String): String {
return URL(url).openConnection().let { connection ->
connection as HttpURLConnection
connection.requestMethod = "GET"
connection.inputStream.bufferedReader().use { it.readText() }
}
}
actual suspend fun post(url: String, body: String): String {
return URL(url).openConnection().let { connection ->
connection as HttpURLConnection
connection.requestMethod = "POST"
connection.doOutput = true
connection.outputStream.write(body.toByteArray())
connection.inputStream.bufferedReader().use { it.readText() }
}
}
}
// In jsMain - using browser fetch API
import kotlinx.coroutines.await
actual class HttpClient {
actual suspend fun get(url: String): String {
return fetch(url).then { it.text() }.await()
}
actual suspend fun post(url: String, body: String): String {
val options = js("{}")
options.method = "POST"
options.body = body
return fetch(url, options).then { it.text() }.await()
}
}
// In iosMain - using NSURLSession
import platform.Foundation.*
actual class HttpClient {
actual suspend fun get(url: String): String {
// Use NSURLSession to make the request
// This is a simplified example
val url = NSURL(string = url)
val request = NSURLRequest(url = url)
// ... additional implementation details
}
actual suspend fun post(url: String, body: String): String {
// ... iOS implementation
}
}
Example 2: Storage Access
Here's how you might implement cross-platform preferences storage:
// In commonMain
expect class PreferencesStorage {
fun saveString(key: String, value: String)
fun getString(key: String, defaultValue: String): String
fun clear()
}
// In androidMain
import android.content.Context
import android.content.SharedPreferences
actual class PreferencesStorage(private val context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE)
actual fun saveString(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}
actual fun getString(key: String, defaultValue: String): String {
return prefs.getString(key, defaultValue) ?: defaultValue
}
actual fun clear() {
prefs.edit().clear().apply()
}
}
// In iosMain
import platform.Foundation.*
actual class PreferencesStorage {
actual fun saveString(key: String, value: String) {
NSUserDefaults.standardUserDefaults.setObject(value, key)
}
actual fun getString(key: String, defaultValue: String): String {
return (NSUserDefaults.standardUserDefaults.stringForKey(key) ?: defaultValue) as String
}
actual fun clear() {
val defaults = NSUserDefaults.standardUserDefaults
defaults.dictionaryRepresentation().keys.forEach {
defaults.removeObjectForKey(it)
}
}
}
Best Practices
-
Keep expect declarations minimal: Only define what's absolutely necessary to be platform-specific.
-
Maintain API consistency: The signatures of your expect and actual declarations must match exactly.
-
Isolate platform-specific code: Try to keep as much logic as possible in the common module and only use expect/actual for platform-specific APIs.
-
Use typealias when possible: For simple type differences, consider using a typealias:
// In commonMain
expect class AtomicInteger(initialValue: Int) {
fun get(): Int
fun set(newValue: Int)
fun incrementAndGet(): Int
}
// In jvmMain
import java.util.concurrent.atomic.AtomicInteger as JavaAtomicInteger
actual typealias AtomicInteger = JavaAtomicInteger
- Default implementations: If one platform doesn't need special behavior, you can provide a simple default:
// In commonMain
expect fun vibrate()
// In androidMain
import android.os.Vibrator
actual fun vibrate() {
// Use Android Vibrator service
}
// In iosMain
actual fun vibrate() {
// Use iOS haptic feedback
}
// In jsMain
actual fun vibrate() {
// Browser might not support vibration, so we provide an empty implementation
console.log("Vibration not supported in this browser")
}
Common Challenges and Solutions
Challenge 1: Handling Dependencies
When using expect/actual with platform-specific libraries, you'll need to add those dependencies to the appropriate source sets:
// build.gradle.kts
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
}
}
val androidMain by getting {
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.10.0")
}
}
val iosMain by getting {
// iOS-specific dependencies would go here if needed
}
}
}
Challenge 2: Dealing with Platform Types
Sometimes you need to work with platform-specific types:
// In commonMain
expect class PlatformFile {
fun readText(): String
fun writeText(text: String)
}
// In jvmMain
import java.io.File as JavaFile
actual class PlatformFile(private val file: JavaFile) {
constructor(path: String) : this(JavaFile(path))
actual fun readText(): String = file.readText()
actual fun writeText(text: String) = file.writeText(text)
}
// In jsMain
actual class PlatformFile(private val path: String) {
actual fun readText(): String {
// JavaScript implementation using Node.js fs module or other APIs
return js("require('fs').readFileSync(path, 'utf8')")
}
actual fun writeText(text: String) {
js("require('fs').writeFileSync(path, text)")
}
}
Summary
Kotlin's expect/actual mechanism is a powerful feature that enables true code sharing in multiplatform projects while still allowing for platform-specific implementations when needed. Here's what we've covered:
- The basic concept of expect/actual declarations
- How to use expect/actual with functions, properties, and classes
- Real-world examples for networking and storage
- Best practices for working with expect/actual declarations
- Common challenges and their solutions
By mastering expect/actual, you can create Kotlin Multiplatform applications that share business logic across platforms while still taking full advantage of platform-specific capabilities.
Additional Resources
- Official Kotlin Documentation on Multiplatform
- Kotlin Multiplatform Mobile Developer Portal
- KMP Samples Repository
Exercises
- Create an expect/actual implementation for a simple logger that outputs to the console on JS/JVM and to Logcat on Android.
- Implement a cross-platform file picker using expect/actual declarations.
- Create a location service that works on Android, iOS, and browser using the appropriate location APIs for each platform.
- Build a date formatter with expect/actual that formats dates according to the platform's locale settings.
- Implement a platform-specific biometric authentication service using expect/actual declarations.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)