Engineering

Every expect declaration is a promise you made twice

The compiler verifies an expect declaration's shape on every platform and nothing verifies its meaning. The narrowest declaration is the only promise you can keep.

MobileByteSensei 10 min read
Two bells cast to the same drawing, which is not the same as to the same note
kotlinmultiplatformarchitecture

An expect declaration says: this exists on every platform, with this shape. The compiler enforces that completely. If a platform is missing an actual, the build fails; if a signature differs, the build fails.

What the compiler does not check — cannot check — is whether the two implementations mean the same thing.

The case this post is about

One declaration, promising a biometric prompt on two platforms:

expect fun read(key: String): String?       // shape checked, meaning unspecified

The compiler verifies both platforms have a function with that signature. It verifies nothing about what they do, and the two implementations disagreed: one returned null when the user cancelled, the other threw a platform exception. Both satisfied the declaration.

The caller handled null and crashed on iOS.

This post follows that one signature, because the fix is not a better test — it is a return type that has somewhere to put cancelled, unavailable and failed, so a platform cannot express its outcome as an exception the shared code never agreed to.

The symptom

A function behaves differently on one platform and the code reads as though it could not possibly. There is one declaration, one call site, one name. Everything about the shared code says this is a single behaviour.

The difference is in an actual you have not opened, doing something defensible for its platform that happens not to match what the other one does. Returning an empty collection where the other throws. Treating an absent value as a default where the other treats it as an error. Being case-sensitive where the other is not.

One drawing, two castings, and a difference you only find by striking them

The second thing is that the shared code is where the bug appears and not where it lives. Logic in the common source set calls the declaration and makes an assumption about it — reasonably, based on the one implementation the author had in mind. That assumption is now load-bearing for every platform, and it was never written down anywhere.

The third is how late it is found. The platform whose actual diverges is usually the one that is tested less, run less, or added later. So the divergence sits dormant through all the development of the first platform and surfaces during the second, mixed in with every other integration problem, as one more thing that does not work yet.

The fourth is the shape of the eventual diagnosis. You are debugging common code that is correct, against a declaration that is correct, with two actuals that are each individually reasonable. There is no defective component. The defect is in the relationship between two files that nothing compares.

Why the obvious fix failed

Document the contract in a comment. The standard response and it fails the standard way: the comment lives on the expect declaration, the person writing the second actual is looking at the first actual, and nothing enforces agreement between prose and code. It helps slightly and it is not a mechanism.

Test both actuals. Correct, necessary, and insufficient on its own — because the tests are also written per platform, usually by whoever wrote that actual, encoding the same assumption the actual encodes. Two implementations that disagree tend to come with two test suites that each confirm their own behaviour.

Write one shared test that runs on every platform. This is the real answer and it is weaker than it sounds, because it only covers the behaviour you thought to specify. A shared test suite over an expect declaration is a partial specification, and the divergences that bite are usually in cases nobody wrote a test for — the empty input, the missing key, the concurrent call.

Make the declaration a typed interface instead. Moves the problem rather than solving it. An interface with two implementations has exactly the same property: the compiler checks the shape, and nothing checks that the implementations agree.

Run the shared suite on one platform in continuous integration. This is the compromise that gets made for entirely practical reasons — the other platform's runner is slower, or needs a machine you do not have — and it removes the one mechanism that was covering the gap. A shared test suite executed on a single platform is a platform test suite wearing a shared name, and it will be green for years while the other actual drifts. If only one platform's tests run, the honest description is that the second platform is untested, which is a different and more alarming sentence than "our CI is a bit slow".

The actual mechanism

Since the only thing the compiler verifies is the shape, make the shape carry as much of the meaning as possible — and make the surface as small as you can.

expect fun read(key: String): String?       shape checked, meaning unspecified
expect fun read(key: String): Result<String> shape carries the failure mode

Every behaviour you push into the type is a behaviour the compiler will enforce on both platforms. A nullable return says what happens when there is nothing; a result type says what happens when it fails; a sealed set of outcomes enumerates the cases both actuals must handle. None of that is documentation — it is the one part of the contract that is actually checked.

The second half is the one from the previous argument and it matters more here: the smaller the expect surface, the fewer promises you have to keep twice. A declaration with eight methods is eight opportunities for two implementations to diverge, and a declaration with one method is one.

Tuned against a single fork, not against each other

So the narrowest declaration that does the job is not stylistic preference — it is the only one whose semantics you can realistically hold. The instinct to declare a platform service with a full interface is where the divergence surface comes from; declaring a single function for the one thing that genuinely cannot be shared keeps it at a size you can reason about.

The sub-case that is easiest to get wrong: the degenerate actual. One platform cannot do the thing at all — there is no equivalent API — so its actual returns a default, or does nothing, and compiles. That is the most dangerous outcome available, because the shared code now believes the operation succeeded everywhere. If a platform genuinely cannot perform the operation, the declaration needs to be able to say so, and that means it belongs in the return type rather than in a comment on an empty function body.

There is a structural move that reduces the arithmetic and is worth knowing about, because it is often mistaken for a solution. Intermediate source sets let several platforms share one actual — the Apple targets, say, taking a single implementation between them. That genuinely reduces the number of actuals you maintain, and it changes nothing about the semantics problem: you now have two implementations to keep in agreement instead of four, which is better arithmetic against the same unverifiable property.

It is worth doing for exactly that reason and worth being clear about what it buys. Fewer implementations means fewer opportunities to diverge. It does not mean the remaining ones are checked, and a project that has consolidated well can look like it has solved this when it has only made it smaller.

The fix

narrow      declare the minimum that cannot be shared, not the service around it
type        push failure, absence and outcome cases into the signature
shared test one suite over the declaration, run on every platform
degenerate  a platform that cannot do it must be able to say so in the type

The declaration is three functions wide, and every case a platform might hit is in the return type rather than in a comment:

// commonMain — the PROMISE. Narrow, and it cannot throw a platform exception at you.
sealed interface BiometricResult {
    data object Success : BiometricResult
    data object Cancelled : BiometricResult                   // user dismissed
    data object Unavailable : BiometricResult                 // no hardware, or none enrolled
    data class Failed(val reason: String) : BiometricResult   // everything else, named
}

expect class BiometricPrompt {
    fun isAvailable(): Boolean
    suspend fun authenticate(title: String): BiometricResult
}
// A platform that genuinely cannot do it says so IN THE TYPE — it does not throw,
// and it does not silently return Success, which is the failure that ships.
actual class BiometricPrompt {
    actual fun isAvailable() = false
    actual suspend fun authenticate(title: String) = BiometricResult.Unavailable
}

One suite over the declaration, run on every target — which is what makes the promise checkable rather than asserted:

// commonTest
@Test fun unavailableIsNeverSuccess() = runTest {
    val prompt = BiometricPrompt()
    if (!prompt.isAvailable()) {
        assertEquals(BiometricResult.Unavailable, prompt.authenticate("Unlock"))
    }
}
$ ./gradlew allTests
  jvmTest      BiometricPromptTest  PASSED
  iosX64Test   BiometricPromptTest  PASSED
  desktopTest  BiometricPromptTest  PASSED   <- the degenerate actual, proven honest

The desktop line is the one worth having. Without Unavailable in the type, that platform's only options are to throw or to lie, and the lie compiles.

The third line is worth doing even knowing it is partial. A shared test suite will not catch every divergence, and it will catch the ones you thought about — which is strictly better than two platform suites each confirming their own behaviour, and it gives the next person a place to add a case when a divergence is found.

A bell with a scar where it was brought to pitch, beside one left as cast

What it also does, quietly, is make the contract legible. A shared test over an expect declaration is the closest thing to an executable statement of what the declaration means, and it lives where both implementers will see it.

How we proved it

The compiler's guarantee is exactly and only structural, and that is verifiable rather than asserted: remove an actual and the build fails; change a signature and the build fails; change a behaviour and nothing happens at all. The third case is the whole post, and it is demonstrable in about a minute.

The narrowing argument is the one I act on most. Declaring one function rather than a service interface reduces the divergence surface proportionally, which is not a measurement so much as arithmetic — eight promises against one.

What I have not evidenced is a divergence that bit me in production and was traced back to a mismatched actual. The failure mode described here is one I have reasoned about and designed against rather than one I can narrate. That is a weaker claim than most of what I write, and the structure of the risk — two reasonable files, nothing comparing them — is clear enough that I would rather design against it than wait for the incident.

The degenerate actual is the case I would check for first if I were starting again, because it is the one with a mechanical signature. An actual whose body is empty, returns a constant, or ignores its parameters is not proof of a divergence, but it is a short list worth reading — and unlike semantic agreement, it is something a check can enumerate. I have not built that either, and of everything in this post it is the piece that is clearly buildable.

What I have not solved: detecting divergence mechanically. Nothing in my setup compares two actuals for behavioural agreement, because in general nothing can — it is the equivalence problem, and the honest version of this post is that the mitigation is entirely about reducing surface area and specifying in types, with no check at the end.

One shape, verified on both sides; one meaning, verified on neither

And a limitation of pushing meaning into types: it has a ceiling, and the ceiling is lower than it first appears. A type can say that an operation may fail; it cannot say that one platform's ordering is stable and the other's is not, or that one is case-sensitive. Those are the divergences most likely to occur and least likely to be expressible, which means the strategy works best on exactly the cases that were least dangerous.

What this generalizes to

Any mechanism that checks shape across implementations gives you a guarantee that feels stronger than it is, and the gap is always the same: structure is checkable and meaning is not.

Interfaces, protocols, abstract base classes, schema-validated APIs, duck-typed contracts — in every case the tooling confirms that both sides have the right parts, and in every case two correct-looking implementations can disagree about what the parts do. The compiler's approval is routinely read as approval of the whole contract, because it arrives with the authority of a build failure when it is unhappy.

So for any such boundary, ask what the checker is actually checking, and then push as much meaning as you can into the part that is checked — and keep the surface small, because every element of it is a promise made once per implementation and verified in none of them.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS