Introduction

Kotlin Multiplatform (KMM) is a modern way to write code that works on many platforms such as AndroidiOSdesktop 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/actual mechanism
  • 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:

  1. Every expect has a corresponding actual for each target platform
  2. The signatures match exactly (name, parameters, return type)
  3. 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.

  1. 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.

  1. First, our Modules.kt file at commonMain defines its a Koin sharedModule together with an expect platformModule that is expected to define separate Dependency Injection instances for each platform.
  2. Note we are expecting an HttpClientEngine at the engine parameter 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 in commonMain? Just wow!
  3. Our MoviesDatabase instance utilizes the creation of a MoviesDatabaseFactory object, 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

  1. that the engine that the commomMain Koin Module required gets initialized here and we pass an OkHttp as HttpClientEngine that works fine with Android!
  2. We also see Vault that didn’t exist at commonModule at the first place, initialized passing a Context.
  3. Also, we see that our MoviesDatabaseFactory needed for MoviesDatabase creation gets initialized here, because in the case of Android there is also need of Context.

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.

  1. This time the HttpClientEngine is Darwin that can be used for iOS, rather than the OkHttp that is Java-based which is being used by Android.
  2. We note that we initialized the MoviesDatabaseFactory and Vault to their equivalent classes definitions in iosMain that don’t require passing Context.
  3. We create a SavedStateHandle() wrapper instance to pass a specialized version of the SavedStateHandle so we don’t get crashes on iOS which doesn’t handle app-death similarly to Android, while maintaining parameter passing to the ViewModel via 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:

  1. 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.”
  2. 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 expect version exactly — same name, same parameters, same return type.
  3. Compiler Safety: When you build your project, the Kotlin compiler checks that every expect in 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() or readUserData() 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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

10. Conclusion

The expect/actual mechanism is central to Kotlin Multiplatform. It provides a directcompiler-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/actual contract and its compile-time guarantees.
  • Simple, practical examples for functions, classes, and properties.
  • The key limitation that expect declarations cannot contain implementation code.
  • A real-world pattern for dependency injection using an expect val for Koin modules to provide platform-specific dependencies like Ktor’s HttpClientEngine and 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.

This article was previously published on proandroiddev.com.

Menu