There is no File in commonMain, and that is a feature
wasm has no filesystem and iOS has a sandbox with policy. An interface forces you to name what you need.
TL;DR
java.io.File is not available in shared code, and there is no multiplatform equivalent in the
standard library. The usual reaction is to write an expect/actual wrapper around a path string.
That wrapper is the mistake. A path is the one thing the four targets genuinely do not share: Android has app-private directories, iOS has a sandbox where some directories are reclaimable by the OS, desktop has a real filesystem with user permissions, and wasm has no filesystem at all.
Model what you need — a key-value store, a blob store, a document the user picked — rather than a path. Each of those has an honest implementation on every target. "A file at a path" does not.
The case this post is about
A feature caching downloaded article bodies. The first design is a file wrapper:
// commonMain — the tempting shape
expect class PlatformFile(path: String) {
fun exists(): Boolean
fun readText(): String
fun writeText(text: String)
fun delete()
}
class ArticleCache {
fun store(id: String, body: String) =
PlatformFile("$cacheDir/articles/$id.txt").writeText(body)
fun load(id: String): String? =
PlatformFile("$cacheDir/articles/$id.txt").takeIf { it.exists() }?.readText()
}
Android, iOS and desktop implemented cleanly. Then wasm, where there was nothing to implement, and the design had to be reconsidered from the start rather than extended.
The symptom
Four problems, and the first three are all consequences of modelling a path.
wasm has nothing to map onto. No File, no path, no synchronous read. The only durable
browser stores are IndexedDB and OPFS, both asynchronous — so the signature itself is
unimplementable, not just the body:
fun readText(): String // cannot be honoured in a browser. Not "hard" — impossible.
iOS silently lost the cache. The directory resolved under Caches/, which iOS is permitted to
empty under storage pressure. Not a bug — documented behaviour — and the same class as the database
problem from earlier in this series.
Desktop threw on a path that was fine on Android. $cacheDir/articles/ did not exist, because
mkdirs had never been called — Android's getCacheDir() guarantees existence and a plain
filesystem does not.
And a path-traversal hole. id came from a server payload, and PlatformFile("…/$id.txt")
with id = "../../../prefs" is a write outside the cache directory. The wrapper made that look
like ordinary string interpolation.
Why the obvious fix failed
"Make the methods suspend." Necessary and insufficient. It fixes the signature for wasm and
leaves path: String as the interface's central concept, which is the part that does not
translate — a browser store has keys, not paths.
"Use an in-memory map on wasm." My first wasm actual, and it is a lie by omission: writeText
returns, the caller believes the data is durable, and a page reload loses it. If a platform cannot
provide durability, the API has to say so rather than pretend.
"Use okio's FileSystem." A good library and the right answer for targets with a filesystem.
FileSystem.SYSTEM does not exist on wasm, so the shared layer still cannot assume it — okio
raises the floor without removing the boundary.
"Pick a different base directory on iOS." Correct for the reclaim problem, and it fixes one platform's policy while the shared code still thinks in paths. The next platform-specific policy will cost another round.
The actual mechanism
"A file" bundles four assumptions, and each one fails somewhere:
A hierarchical path namespace. Browsers do not have one. OPFS has a directory-like API, but nothing resembling an absolute path you can interpolate.
Synchronous access. Fine on a real filesystem; impossible on IndexedDB and inadvisable on any main thread.
Durability. Android app-private storage is durable. iOS Caches/ is explicitly reclaimable.
Browser storage can be evicted under pressure or cleared by the user at any time.
A location you choose. On desktop you may write anywhere the user permits. On mobile you are sandboxed. In a browser you get an origin-scoped store and no choice at all.
So "file at a path" is not a lowest common denominator — it is an Android/desktop concept with two targets that cannot supply it.

Model the capability, not the mechanism. "Store bytes under a key, durably" has an honest implementation on four targets. "Open a file at this path, synchronously" has two.
There is a fifth assumption hiding in that sentence, and it is the one that produced our only
data-loss incident: atomicity. "Store bytes under a key" says nothing about what a reader sees
if the process dies halfway through a put.
The four targets answer differently, and none of them answers in the interface:
filesystem (Android/desktop) partial file, unless you write-temp-then-rename
IndexedDB (wasm/js) transactional — the put either happened or did not
OPFS depends on whether you used a sync access handle
iOS Caches/ partial file, and the OS may delete it independently
So two targets give you a transaction for free and two give you a truncated blob that get() will
happily return. A cache survives that; a draft the user typed does not.
The fix is to make it explicit rather than inherited — either the implementation guarantees
atomic replacement (temp file plus rename on the filesystem targets), or durability grows a
second axis and callers handle a torn value. We chose the first, because "sometimes returns half a
document" is not a property any caller can reasonably be asked to code against.
The fix
Three narrow interfaces instead of one file wrapper, each declaring its own durability.
// commonMain
enum class Durability { Durable, Evictable, None }
/** Bytes under a key. No paths — the implementation owns placement. */
interface BlobStore {
val durability: Durability
suspend fun put(key: String, bytes: ByteArray)
suspend fun get(key: String): ByteArray?
suspend fun remove(key: String)
}
Keys, not paths, is the decision that fixes three symptoms at once — and the key is validated in one place rather than at every call site:
// commonMain
private val KEY = Regex("[A-Za-z0-9_-]{1,128}")
internal fun requireValidKey(key: String) =
require(KEY.matches(key)) { "invalid blob key: $key" }
That is the traversal fix. ../../../prefs is not a valid key, and because the implementation —
not the caller — decides where a key lands, there is no interpolation to get wrong.
// androidMain — app-private, durable, directory guaranteed
actual class FileBlobStore(private val dir: File) : BlobStore {
override val durability = Durability.Durable
init { dir.mkdirs() } // the desktop bug, fixed for everyone
override suspend fun put(key: String, bytes: ByteArray) = withContext(io) {
requireValidKey(key); File(dir, key).writeBytes(bytes)
}
…
}
// iosMain — Application Support, NOT Caches. Durable by choice.
actual fun blobStore(): BlobStore = FileBlobStore(
NSFileManager.defaultManager.URLForDirectory(
NSApplicationSupportDirectory, NSUserDomainMask, null, true, null
)!!.path!!.let(::File)
)
// wasmJsMain — OPFS. Asynchronous, and honestly Evictable.
actual fun blobStore(): BlobStore = OpfsBlobStore().also {
check(it.durability == Durability.Evictable)
}
The durability field is what makes the wasm implementation honest rather than a quiet lie. A
caller can act on it:
// commonMain
suspend fun cacheArticle(id: String, body: String) {
store.put(id, body.encodeToByteArray())
// Anything not guaranteed to survive must be re-fetchable. Say so at the call site.
if (store.durability != Durability.Durable) markRefetchable(id)
}
And the other two capabilities get their own interfaces, because they have genuinely different semantics:
// commonMain
/** Small structured values. Backed by preferences/localStorage, not files. */
interface KeyValueStore {
suspend fun putString(key: String, value: String)
suspend fun getString(key: String): String?
}
/** A document the USER chose. The platform picker owns the path; we never construct one. */
interface DocumentPicker {
suspend fun pickForReading(mimeTypes: List<String>): DocumentHandle?
}
DocumentPicker is the case where a path genuinely exists and still should not be in your shared
code: on Android it is a content URI with a grant, on iOS a security-scoped URL, in a browser a
File from an input element. A handle you pass back to the platform is the only portable shape.
How we proved it
That every target satisfies the same contract. One test suite in commonTest, run on all
four:
// commonTest
@Test
fun roundTripsBytesUnderAKey() = runTest {
val store = blobStore()
store.put("article-42", "hello".encodeToByteArray())
assertEquals("hello", store.get("article-42")!!.decodeToString())
store.remove("article-42")
assertNull(store.get("article-42"))
}
@Test
fun rejectsTraversalKeys() = runTest {
val store = blobStore()
listOf("../prefs", "a/b", "", "x".repeat(129)).forEach { bad ->
assertFailsWith<IllegalArgumentException>("accepted: $bad") {
store.put(bad, ByteArray(1))
}
}
}
jvmTest(desktop) PASSED durability=Durable
androidUnitTest PASSED durability=Durable
iosSimulatorArm64Test PASSED durability=Durable
wasmJsTest PASSED durability=Evictable
Four targets passing one contract — and the fourth reporting a different durability rather than claiming the same one. That column is the design working.
That the traversal hole is closed. The second test above, run against the old implementation for comparison:
old PlatformFile("$dir/articles/$id.txt") with id="../../../prefs"
→ wrote to <app>/prefs.txt ← outside the cache dir
new store.put("../../../prefs", …)
→ IllegalArgumentException: invalid blob key
And that the iOS directory is the durable one — asserting the policy rather than waiting for the OS to act, which is the same technique the database post needed:
resolved base directory, on device
before: …/Library/Caches/ ← reclaimable
after: …/Library/Application Support/
Two limits. Durability.Evictable on wasm is a declaration, not a guarantee of how often
eviction happens — browsers differ and the Storage API's persistence request may be denied, so the
honest claim is "not guaranteed", not a rate. And this covers bytes-under-a-key; streaming large
files (a video download, a multi-gigabyte export) needs a different interface again, because
holding a ByteArray is itself an assumption that fails at size.
What this generalizes to
Model capabilities, not mechanisms. "Store bytes under a key" ports; "open a file at a path" does not. The useful question for any shared abstraction is what the caller actually needs, which is almost always narrower and more portable than the familiar API.
A platform that cannot implement your interface is telling you the interface is wrong. wasm was not the awkward target — it was the one that revealed that three of the four file assumptions were not shared. The most constrained consumer is the best API reviewer you have.
Declare the property you cannot guarantee. durability in the interface turns an invisible
difference into something a caller can branch on. An implementation that silently fails to provide
what its signature implies is worse than one that refuses.
Let the implementation own placement, and validate at the boundary. Removing path construction from the caller eliminated a traversal vulnerability as a side effect of a portability fix. Keys that the store maps to locations are safer than paths the caller composes.
Related
- Your shared database needs a driver per platform, and that is the easy half — the same iOS reclaim policy, for SQLite
- Hashing a password in shared code is the one thing you should not share — another capability that belongs at the platform
- expect/actual is not an interface, and the difference shows up at link time — why the seam is an interface plus a factory
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".