
Introduction
Kotlin Multiplatform (KMM) is a modern way to write code that works on many platforms such as Android, iOS, desktop computers, and even web applications. Instead of writing the same code many times for different platforms, you write it once and share it. This saves time, reduces mistakes, and makes your apps more consistent.
One of the powerful features of Kotlin Multiplatform is the expect/actual mechanism. This article explains what “expect/actual” is, how it works, and why it is very useful in modern cross-platform development.
What You’ll Learn
- Core concepts of Kotlin Multiplatform and the
expect/actualmechanism - Practical implementation patterns with real-world examples
- Advanced dependency injection using Koin 4.1.0+
- Comparison with alternative approaches like interfaces
- Best practices and common pitfalls to avoid
By the end of this article, you will have a strong understanding of the “expect/actual” mechanism in Kotlin Multiplatform, and you will be able to apply these ideas in your projects.
For more details, see the official Kotlin documentation: Expect and Actual Declarations | Kotlin Multiplatform.
1. Understanding Kotlin Multiplatform
Kotlin Multiplatform (KMM) Overview
Kotlin Multiplatform is a way to write code that can run on many different platforms without having to rewrite the same logic over and over again.
Imagine you are writing a recipe. Instead of writing separate recipes for each family member, you write one recipe that everyone can use, but sometimes you add a small note on how to adjust it for each person’s taste.
Benefits of Kotlin Multiplatform
- Code Reusability: Write business logic, data models, and algorithms once, use everywhere
- Type Safety: Kotlin’s strong typing extends across all platforms, catching errors at compile time
- Gradual Adoption: Start with shared modules and expand as needed — you’re not forced to share everything
- Native Performance: Compiles to native code on each platform, ensuring optimal performance
- Tooling Support: Full IDE support with code completion, refactoring, and debugging
However, KMP does not force you to share everything. For instance, UI code is often different for Android and iOS, because each platform has its own design language and frameworks.
KMP provides the option to share what makes sense (like data handling or domain logic) and keep platform-specific components separate.
2. The “Expect Actual” Mechanism Explained
Even though Kotlin Multiplatform allows you to share a lot of code, some parts of your application need to work differently depending on the platform. For example, reading a file or saving data might work differently on an Android phone than on an iPhone.
2.1 The Core Concept
You can think of “expect/actual” like a contract:
expect: In your shared (common) module, you declare what you need, such as a function or class for storage.actual: In each platform-specific module (Android, iOS, etc.), you provide the concrete implementation of that function or class.
This approach keeps your shared code simple, because it just calls getBatteryLevel() or getPlatformName(), and it does not care how each different platform might approach the request, as long as it replies with the expected response.
2.2 How It Works Under the Hood
When you declare an expect function, class, or property, you’re essentially creating a placeholder that the Kotlin compiler will resolve at build time.
Each target platform must provide a matching actual implementation, or compilation fails.
// Common module - the contract expect fun getPlatformName(): String // Android module - the implementation actual fun getPlatformName(): String = "Android" // iOS module - the implementation actual fun getPlatformName(): String = "iOS"
2.3 Compiler Enforcement
The beauty of expect/actual lies in its compile-time safety. Unlike runtime checks or dependency injection alone, the compiler ensures:
- Every
expecthas a correspondingactualfor each target platform - The signatures match exactly (name, parameters, return type)
- No platform is accidentally left without implementation
3. What Can Use Expect/Actual?
The mechanism supports various declaration types:
Functions — Most common use case
expect fun getCurrentTimestamp(): Long
Properties — For platform-specific values
expect val deviceId: String
Classes — When entire objects need platform-specific behavior
expect class FileManager() {
fun readFile(path: String): String
fun writeFile(path: String, content: String)
}
Objects — For singleton platform services
expect object PlatformConfig {
val apiEndpoint: String
}
Interfaces, Enums, and Annotations — Less common but supported
4. Learn by Example
4.1. Step-by-Step Explanation by a Simple Example
Let’s say we want to use some function that is defined differently for Android and iOS.
- Declare the Requirement with
expect:
In the common code, you write a declaration without a body.
expect fun getCurrentTime(): Long
This line means: “I need a function called getCurrentTime() that returns a number (Long), but I will explain how it works later for each platform.”
2. Provide the Details with actual:
In the platform-specific code, you write the implementation.
// Android actual fun getCurrentTime(): Long = System.currentTimeMillis()
// iOS import platform.Foundation.NSDate import platform.Foundation.timeIntervalSince1970 actual fun getCurrentTime(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
Each target platform must provide a matching function to satisfy the expect contract.
3. Use the Function in Shared Code:
Now, in your shared module, you can use the function without worrying about the platform differences:
val currentTime = getCurrentTime()
println("Current Time is: $currentTime")
4. Compiler Safety:
The Kotlin compiler checks that every expect has a matching actual. If one platform is missing the implementation, the code will not compile. This makes the mechanism safe and reliable.
That’s it! Now, from any shared Kotlin code, you can call
getCurrentTime() and know that the correct implementation will be used at runtime, depending on whether you are on Android or iOS.
4.2 A “Hello World” Class Example: Identifying the Platform
As already mentioned, you are not limited to functions. You can also use expect/actual on classes, properties, interfaces, and even annotation classes. A classic “hello world” example for Kotlin Multiplatform is to get the name of the operating system.
Let’s define a Platform class that holds the name of the current platform.
1. Define the expect class in the common module:
// Common module
expect class Platform() {
val name: String
}
This declares that we expect a class named Platform to exist on every target. This class must have a property called name of type String.
2. Provide the actual class for Android: On Android, we can use the Build class to get the OS version.
// androidMain module
import android.os.Build
actual class Platform actual constructor() {
actual val name: String = "Android ${Build.VERSION.SDK_INT}"
}
3. Provide the actual class for iOS: On iOS, we can use UIDevice from the UIKit framework.
// iosMain module
import platform.UIKit.UIDevice
actual class Platform actual constructor() {
actual val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
Now, you can instantiate and use this class in your shared code at commonMain:
// In your shared code
val platform = Platform()
println("Hello from ${platform.name}!")
This simple example shows how expect/actual can be used on classes to provide platform-specific data, keeping the shared code that uses it clean and unaware of the underlying platform details.
5. Limitations
expect Declarations Cannot Contain Code
A common point of confusion for developers is how expect relates to concepts like abstract classes or interfaces with default methods. It’s crucial to understand a fundamental rule: an expect declaration is purely a template. It cannot contain any concrete default implementation code or logic in the common module.
You cannot mix implemented methods with expected methods in the same expect block.
What You CANNOT Do The following code will fail to compile because it tries to add shared logic directly inside an expect class:
// THIS WILL NOT COMPILE!
expect class MixedDeclarations {
// This part is a valid expect function
expect fun platformSpecificLog()
// This part is NOT valid because it has an implementation body {}
fun sharedLog() {
println("This shared logic is not allowed inside an expect class.")
}
}
6. Initializations
Here comes a very crucial part of the expect/actual mechanism that is both practical and powerful.
You see, many times we need to declare a class that can be used in both Android and iOS, but its dependencies during creation may vary between Android and iOS. For example, in Android you might need to make use of a Context which is not applicable in iOS. Or in Ktor, you normally need to define a Darwin http client for iOS and OkHttp for Android.
6.1 Koin
Example by: https://github.com/ioannisa/CMPMasterDetail/
Koin is the most recommended way to create Dependencies for Kotlin Multiplatform.
To create dependencies with Koin you need to register Koin Modules for your project. For KMP you are expected to at least have have one such module per platform module, and another for the commonMain module.
In Koin 4.1.0 the KoinMultiplatformApplication is introduced as a one-line setup for starting Koin and applying modules in our KMP Project.
1. Let’s begin by setting up Koin.
// App.kt (your KMP application entry point)
fun App() {
KoinMultiplatformApplication(
config = KoinConfiguration {
modules(sharedModule, platformModule)
}
) {
MyKMPSuperAppTheme {
// ...
}
}
Here we are exposing two modules, the sharedModule which will contain all common dependency initializations for all platforms and the platformModule which as you can guess is using the expect/actual mechanism to define different dependency initializations per platform.
2. The commonMain Koin Module Code
Let’s hit a scenario where we have an http-client with Ktor and a Movies Database factory for Room.
// commonMain/di/Modules.kt
expect val platformModule: Module
val sharedModule = module {
single<MoviesHttpClient> {
MoviesHttpClient(
engine = get(),
baseUrl = BuildConfig.BASE_URL_MOVIES,
apiKey = BuildConfig.API_KEY_MOVIES,
logging = true
)
}
single {
get<MoviesDatabaseFactory>().create()
.setDriver(BundledSQLiteDriver())
.build() // .build() returns MoviesDatabase instance
}
single { get<MoviesDatabase>().moviesDao }
}
Notice some interesting facts here.
- First, our
Modules.ktfile atcommonMaindefines its a KoinsharedModuletogether with anexpectplatformModulethat is expected to define separate Dependency Injection instances for each platform. - Note we are expecting an
HttpClientEngineat theengineparameter for our network setup. Seems like we are going to define that differently in our platforms. Wow, we are going to define part of the dependency for network per platform, while the rest of it incommonMain? Just wow! - Our
MoviesDatabaseinstance utilizes the creation of aMoviesDatabaseFactoryobject, and while just creating it here, we will interestingly see it is defined differently for iOS and Android.
3. The androidMain Koin Module
Here we witness the actual implementation of the platformModule for Android!
Yes, everything here is customized just for object dependencies provided for the Android platform.
actual val platformModule: Module
get() = module {
single<HttpClientEngine> {
OkHttp.create()
}
single<Vault> {
Vault(androidApplication())
}
single {
MoviesDatabaseFactory(androidApplication())
}
}
Ok, here we see
- that the
enginethat thecommomMainKoin Module required gets initialized here and we pass anOkHttpasHttpClientEnginethat works fine with Android! - We also see
Vaultthat didn’t exist atcommonModuleat the first place, initialized passing aContext. - Also, we see that our
MoviesDatabaseFactoryneeded forMoviesDatabasecreation gets initialized here, because in the case of Android there is also need ofContext.
4. The iosMain Koin Module
Now is the time for the actual implementation of the platformModule for iOS! So here we have object creations only for builds targeting iOS devices.
actual val platformModule: Module
get() = module {
single<HttpClientEngine> {
Darwin.create()
}
single {
MoviesDatabaseFactory()
}
single<Vault> {
Vault()
}
factory<SavedStateHandle> {
SavedStateHandle()
}
}
Here we see even more interesting facts about the way our actual mechanism works for providing specialized object instances for iOS builds.
- This time the
HttpClientEngineisDarwinthat can be used for iOS, rather than theOkHttpthat is Java-based which is being used by Android. - We note that we initialized the
MoviesDatabaseFactoryandVaultto their equivalent classes definitions iniosMainthat don’t require passingContext. - We create a
SavedStateHandle()wrapper instance to pass a specialized version of theSavedStateHandleso we don’t get crashes on iOS which doesn’t handle app-death similarly to Android, while maintaining parameter passing to theViewModelvia our navigation component.
7. TL;DR Explanation of Expect Actual
How Expect Actual Works
expect/actual is a feature built into the Kotlin language to handle platform differences in KMM. Here’s the process step by step:
- Expect Declaration: In the common module, you use expect to define a function, property, or class without writing its implementation. This tells the Kotlin compiler, “This will exist, but the platforms will fill in the details.”
- Actual Declaration: In each platform-specific module (like Android or iOS), you use actual to provide the real code for that declaration. The actual version must match the
expectversion exactly — same name, same parameters, same return type. - Compiler Safety: When you build your project, the Kotlin compiler checks that every
expectin the common module has a matching actual in every platform module. If one is missing (say, you forgot the iOS version), the compiler stops the build with an error, preventing broken apps.
This system ensures that your shared code can depend on certain features being available, while each platform handles them in its own way.
8. Comparing “Expect Actual” and Interfaces
Both “expect actual” and interfaces allow you to define a contract for your code. However, they work in different ways.
8.1. Interfaces
With interfaces, you define functions in a common module, and then each platform must provide its own version. However, interfaces have some differences:
- Default Implementations: Interfaces in Kotlin can have default implementations. This means you can write some code in the interface itself.
- Extra Setup Needed: Using interfaces often requires additional work like setting up factories or dependency injection. This can increase the amount of code you need to write.
8.2. Advantages of “Expect Actual”
- Less Boilerplate: “Expect actual” is direct. You declare your need in the common module and then simply provide the details on each platform without extra setup.
- Compiler Checks: The Kotlin compiler makes sure every expect has a matching actual. If you forget one, you will see an error at compile time rather than a runtime crash.
| Aspect | Expect Actual | Interfaces |
|---|---|---|
| Abstraction | Defines platform-specific contracts | Defines shared contracts |
| Implementation | Direct, no extra classes needed | Requires platform-specific classes |
| Default Implementations | Not supported in expect | Supported, can be overridden |
| Complexity | Lower, built into KMM | Higher, needs factory or DI |
| Use Case | Platform-specific logic | Shared logic with optional overrides |
9. Best Practices and Limitations
9.1. Best Practices
To make the most out of the “expect actual” mechanism, follow these guidelines:
- Keep Expect Declarations Small: Only declare what really needs to be different between platforms. This keeps your shared module clean.
- Use Clear, Descriptive Names: Use names like
getPlatformName() orreadUserData() so that it is obvious what the function does. - Write Thorough Tests: Make sure that the actual implementations on each platform work correctly by writing unit tests for each platform.
- Document Your Code: Explain in comments or documentation why you are using “expect actual” and what each implementation is supposed to do. This is very helpful for teams and future maintenance.
- Consider Interfaces for Shared Logic: When you need default behavior or more flexibility, consider using interfaces instead of “expect actual”.
9.2. Limitations to Be Aware Of
- No Default Code in Expect: Since you cannot provide a default implementation in the common module, you must duplicate some logic if it applies to all platforms.
- Maintenance Overhead: In large projects with many platforms, you may need to write many actual implementations. Good planning and documentation are essential.
- Not Suitable for UI Code: The “expect actual” mechanism works best for non-UI logic. Since each platform has its own design language and framework, user interface code often remains platform-specific.
Job Offers
10. Conclusion
The expect/actual mechanism is central to Kotlin Multiplatform. It provides a direct, compiler-enforced way to manage platform-specific implementations, making cross-platform code more reliable and maintainable. While interfaces offer flexibility with default implementations, expect/actual shines with its simplicity and compile-time safety for direct platform access.
We’ve explored:
- The fundamental
expect/actualcontract and its compile-time guarantees. - Simple, practical examples for functions, classes, and properties.
- The key limitation that
expectdeclarations cannot contain implementation code. - A real-world pattern for dependency injection using an
expect valfor Koin modules to provide platform-specific dependencies like Ktor’sHttpClientEngineand database factories.
By understanding these patterns and applying the best practices outlined, you can leverage expect/actual to build efficient, scalable, and maintainable cross-platform applications, maximizing code sharing without sacrificing access to native capabilities.
Further Reading
This article was previously published on proandroiddev.com.


