Engineering

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.

MobileByteSensei 11 min read
kmpkotlinarchitecture

TL;DR

  • A one-line actual typealias turned 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 class surfaced 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.

A four-member expect declaration whose typealias actual makes sixty-one members reachable in Android platform code and forty-eight in iOS code, while shared code still sees only the declared four so no build ever reports the widening

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 two legal forms of an actual compared by what each promises, with a class declaring exactly its own members and a typealias asserting that the expect is the existing type, so every member and supertype comes with it

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.

An audit across eleven expect declarations comparing declared members against those a typealias target makes reachable, finding two that publish a wider surface and leaving same-surface aliases alone

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.


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

Follow by RSS