actual typealias looks like a shortcut and changes what you promised
Aliasing to a platform type exports that type's whole surface to every caller.
TL;DR
- A one-line
actual typealiasturned a four-method expect class into a public API with 61 members on Android and 48 on iOS — and common code compiled against only the four, so nothing reported the widening. - Platform code then used the extra members, because they were there and they were useful. Twenty call sites, across two source sets, none of them a rule violation.
- Replacing the alias with a real
actual classsurfaced them: 14 compiler errors across 9 files on Android alone. Nine of those call sites were legitimate and moved into the expect contract; five were platform shortcuts that had no business in shared architecture. - What reading on buys: why the compiler cannot warn about this, the audit that counts the real surface, and the one case where a typealias is the right answer.
The case this post is about
A key-value store, declared in common with the four operations the app needs:
// commonMain/kotlin/storage/Prefs.kt
expect class Prefs {
fun getString(key: String): String?
fun putString(key: String, value: String)
fun remove(key: String)
fun clear()
}
And the Android actual, written the obvious way, because a platform type already does this:
// androidMain/kotlin/storage/Prefs.android.kt
actual typealias Prefs = android.content.SharedPreferences
That very nearly compiles, and the way it fails is the least interesting part: SharedPreferences
has getString, but putString lives on Editor, not on SharedPreferences itself — so the
alias misses a member the expect declares and the compiler says so. That is a contract mismatch,
which is the error the mechanism is SUPPOSED to produce — a missing member is exactly what an
actual is checked for. The version that did compile aliased a class that implements
SharedPreferences and adds the writes, and it is the one that compiled CLEANLY that caused the
problem:
// androidMain/kotlin/storage/DataStorePrefs.kt
//
// Implements SharedPreferences by delegation so existing Android code keeps working, and adds
// the four write helpers the shared contract wants. This class is REASONABLE. It is the
// typealias on the next line that does the damage.
internal class DataStorePrefs(
private val delegate: SharedPreferences,
) : SharedPreferences by delegate {
fun putString(key: String, value: String) = delegate.edit().putString(key, value).apply()
fun remove(key: String) = delegate.edit().remove(key).apply()
fun clear() = delegate.edit().clear().apply()
}
// androidMain — the one that shipped
actual typealias Prefs = DataStorePrefs
Four methods declared, one line of actual, and ./gradlew build green on all five targets.
getString arrives through the delegated SharedPreferences supertype and the other three are
declared on the class, so the expect is satisfied — along with every
other member the delegated supertype brings, which is the surface the audit later measures.
The symptom
Six months later, a routine change: swap DataStore for a different implementation on Android. The expectation was a one-file change behind a four-method contract.
$ # replace the typealias with a real class
$ ./gradlew :shared:compileKotlinAndroid
e: Unresolved reference: asMap
androidMain/kotlin/sync/SyncState.kt:41
e: Unresolved reference: toPreferences
androidMain/kotlin/sync/SyncState.kt:58
e: Unresolved reference: contains
androidMain/kotlin/analytics/Session.kt:22
… 14 errors in 9 files
Fourteen call sites using members that were never in the contract. And the iOS side had the same
shape with a different set — NSUserDefaults members, used in six places:
e: Unresolved reference: dictionaryRepresentation
iosMain/kotlin/debug/PrefsDump.kt:17
None of those files was wrong when it was written. The member was there, it was public, the IDE offered it, and the code compiled. The contract said four methods; the type in scope had dozens.

Why the obvious fix failed
The first instinct is that this is a discipline problem, fixable with a rule: platform code must only use the expect members. That is unenforceable by inspection, and I tried the mechanical version before admitting it:
# forbid platform code from naming non-contract members
$ grep -rn 'prefs\.' androidMain/ | grep -vE '\.(getString|putString|remove|clear)\(' | wc -l
14
Which found the 14 and is wrong as a gate, for the reason every text-matching gate here is wrong:
a member can be reached without the receiver being spelled prefs — through a local, an extension
function, an apply block, a destructuring:
with(prefs) { asMap() } // no `prefs.` anywhere
fun Prefs.dump() = asMap() // an extension on the alias
The second attempt was to make the alias private, which is not a thing:
private actual typealias Prefs = MutablePreferences
e: 'actual typealias' must have the same visibility as the corresponding expect declaration
The expect is public, so the actual is public, so the aliased type's surface is public. There is no narrowing modifier.
The third attempt was the honest one and it is what shipped — but it is worth being precise about why the first two failed, because "just be careful" is the answer most teams land on and it is the one that produced 20 call sites over six months.
The actual mechanism
actual class and actual typealias are both legal actuals, and they make different promises.
actual class declares a new type whose members are exactly what you write. Common code sees
the expect's members; platform code sees the actual's. If the actual declares only the four
methods, four is all anybody gets.
actual typealias says "the expect IS this existing type". Not "is implemented by" — is.
Prefs becomes another spelling of MutablePreferences, with identical identity, so every
member, every extension in scope, and every supertype comes with it:
expect class Prefs declares 4 members
actual typealias → … Prefs HAS 61 members on Android
Prefs HAS 48 members on iOS
commonMain sees 4
androidMain sees 61
And the compiler cannot flag the difference, because the difference is not an error. An actual
is required to satisfy the expect — every expect member must exist with a compatible signature.
Having extra members is not a violation of that requirement; it is the normal state of every
actual class too, since an actual may legitimately declare platform-only helpers. The rule the
alias breaks is not "no extra members", it is a design intent — the contract is the whole surface —
and no compiler has access to that intent.
So the alias does not widen the contract. It widens the type in scope in platform source sets, which is a different thing and is invisible from common code. Every green build was accurate: the common contract was four methods the whole time, and 20 call sites were depending on a type identity that the contract never promised to keep.
The reason that matters for maintenance is specific: an actual class can be replaced by changing
one file, because nothing outside it knows the implementation's type. An actual typealias has
published the implementation's type into every platform source set, so replacing it is a
breaking change scoped to however much platform code happened to use it — a number nobody tracks
and nothing reports.

The fix
A real actual class delegating to the platform type, with the platform-only additions that turned
out to be legitimate promoted into the expect contract.
// commonMain/kotlin/storage/Prefs.kt
//
// The contract, widened DELIBERATELY: `keys` and `contains` were among the 14 accidental uses
// and are genuinely needed by shared code. Promoting them is the right outcome of the audit —
// the alias had been supplying them by accident, and now they are declared, documented and
// implemented on every target.
expect class Prefs {
fun getString(key: String): String?
fun putString(key: String, value: String)
fun remove(key: String)
fun clear()
/** Every key currently set. Ordering is NOT guaranteed and differs per platform. */
fun keys(): Set<String>
fun contains(key: String): Boolean
}
// androidMain/kotlin/storage/Prefs.android.kt
//
// A real class, so the surface is exactly the six declared members. The delegate is PRIVATE,
// which is the whole point: no platform source set can reach MutablePreferences through Prefs,
// so replacing the implementation is a one-file change again.
actual class Prefs internal constructor(
private val delegate: DataStore<Preferences>,
private val scope: CoroutineScope,
) {
actual fun getString(key: String): String? =
runBlocking { delegate.data.first()[stringPreferencesKey(key)] }
actual fun putString(key: String, value: String) {
scope.launch { delegate.edit { it[stringPreferencesKey(key)] = value } }
}
actual fun remove(key: String) {
scope.launch { delegate.edit { it.remove(stringPreferencesKey(key)) } }
}
actual fun clear() { scope.launch { delegate.edit { it.clear() } } }
actual fun keys(): Set<String> =
runBlocking { delegate.data.first().asMap().keys.map { it.name }.toSet() }
actual fun contains(key: String): Boolean =
runBlocking { delegate.data.first().contains(stringPreferencesKey(key)) }
}
And the audit that counts the real surface, so the next alias is caught at review rather than at the next migration:
// build-logic/src/main/kotlin/ExpectSurfaceAudit.kt
//
// Compares the member count DECLARED by each expect against the member count the actual
// makes REACHABLE. A typealias actual reports the aliased type's full surface, which is the
// signal — an actual class reports its own, which is usually within a member or two of the
// expect.
data class SurfaceDelta(val name: String, val declared: Int, val reachable: Int, val isAlias: Boolean)
fun audit(expects: List<ExpectDecl>, actuals: Map<String, ActualDecl>): List<SurfaceDelta> =
expects.mapNotNull { e ->
val a = actuals[e.qualifiedName] ?: return@mapNotNull null // missing actual: a build error already
val reachable = when (a) {
// The aliased type's own members PLUS its supertypes' — the whole reason the
// number is large. Counting only declared members would miss inherited ones,
// which is where SharedPreferences' surface mostly lives.
is ActualDecl.Alias -> a.target.allMembersIncludingInherited().size
is ActualDecl.Class -> a.declaredMembers.size
}
SurfaceDelta(e.simpleName, e.members.size, reachable, a is ActualDecl.Alias)
}
/**
* Fails on an alias whose target is WIDER than the contract — not on aliases as such. A
* typealias to a type with the same four members is fine and common (a value class, a typed
* id), and a blanket ban would be refused by the team within a week.
*/
fun check(deltas: List<SurfaceDelta>, tolerance: Int = 2): List<String> =
deltas.filter { it.isAlias && it.reachable > it.declared + tolerance }
.map { "${it.name}: expect declares ${it.declared} member(s); the typealias target " +
"makes ${it.reachable} reachable in platform source sets. " +
"Use `actual class` with a private delegate, or widen the expect deliberately." }
And the output it produced across the module's eleven expect declarations:
$ ./gradlew :shared:auditExpectSurface
expect declared reachable actual
Prefs 4 61 typealias ← FAIL
SecureStore 3 3 class
PlatformClock 2 2 typealias (kotlinx Instant — same surface)
Logger 5 7 class
FileHandle 6 44 typealias ← FAIL
… 6 more within tolerance
FAILURE: 2 expect declaration(s) publish a wider surface than they declare.
FileHandle was the second one, aliased to java.io.File on the JVM targets, and it had 9 uses
of non-contract members. We had been looking for one instance of this and found two.
How we proved it
That the 20 call sites were the alias's doing and not a discipline failure — the measurement is to replace the alias with a real class and count what breaks, because that number is exactly the hidden coupling:
$ # actual typealias → actual class, nothing else changed
$ ./gradlew :shared:compileKotlinAndroid 2>&1 | grep -c '^e: Unresolved reference'
14
$ ./gradlew :shared:compileKotlinIosArm64 2>&1 | grep -c '^e: Unresolved reference'
6
Twenty call sites across two source sets, none of which a green build had ever mentioned.
That the grep-based gate cannot find them. The fixture is the four reachability forms the text match misses, and the gate must fail all four:
$ ./gradlew :fixtures:alias-escapes:auditExpectSurface
Prefs: expect declares 4 member(s); the typealias target makes 61 reachable
$ grep -rn 'prefs\.' fixtures/alias-escapes/ | grep -vE '\.(getString|putString|remove|clear)\(' | wc -l
0 ← the grep finds NONE of the four
Zero by grep, caught by the audit. with(prefs) { }, an extension function, an apply block and a
local alias each reach a non-contract member without the receiver appearing next to a dot.
That nine of the twenty were legitimate. Classifying them was manual and is the part worth reporting honestly, because the gate cannot do it:
keys() / contains() 9 uses → promoted into the expect contract
asMap() for a debug dump 5 uses → moved behind a platform-only DebugPrefs, not in shared
toPreferences() round-trip 4 uses → deleted; it was re-implementing putString
dictionaryRepresentation 2 uses → moved behind the same DebugPrefs
Nine promoted, eleven removed or relocated. If the honest answer had been "all twenty are legitimate", the expect was simply wrong and should have declared twenty members — which is a fine outcome and not the one we had.
And the case where a typealias is correct, which is why the gate has a tolerance rather than a ban:
expect class PlatformClock { fun now(): Instant }
actual typealias PlatformClock = kotlinx.datetime.Clock.System // 2 declared, 2 reachable
The target's surface is the contract, there is no wider type to leak, and writing a delegating class would add indirection for nothing. Roughly half the aliases in the module are this shape.
What I cannot tell you is whether the +2 tolerance is right in general. It was chosen to let
Logger through — a class with two platform-only helpers nobody misuses — and a codebase with
richer platform types would want it tighter. The number is a knob; the measurement it enables is
the transferable part.

What this generalizes to
Two constructs that satisfy the same requirement can publish very different amounts of surface,
and a type system checks the requirement rather than the intent. actual typealias satisfies
"provide an implementation" perfectly and quietly answers a second question — what else is in
scope — in the most generous possible way. The reliable habit is to ask, of any delegation or
aliasing shortcut, what a consumer can now reach that the declaration does not mention, and to
count it rather than reason about it: the count is cheap, and the reasoning was wrong by fourteen
call sites over six months.
Related
- An expect declaration cannot carry default arguments — the other half of the expect/actual contract, and which side owns which part of a signature.
- Source sets are a graph, and iosMain is not where you think it is — where an actual has to live for a target to see it at all.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".