0

Say I'm producing a finite stream of messages as a Flow<Message>. I loop over some source of messages, and, when there are no more messages, break out of the loop to finish the flow normally.

val messages = flow {
    while (source.hasNext()) {
        emit(source.next())
    }
    println("Finished sending all the messages")
}

The consumer simply calls collect to receive each message in turn until the flow is finished.

messages.collect { doSomethingWith(it) }
println("All the messages have been processed")

But what happens when I need to tell the caller why the flow stopped? For example, let's say the message producer requires authentication, and I have to deal with token expiry. Now there are two reasons the flow might be terminated, and so my producer looks like this:

val messages = flow {
    while(source.hasNext()) {
        if (authentication.isExpired) {
            break
        }
        emit(source.next())
    }
    println("Finished sending all the messages")
}

The problem with this is that the consumer doesn't know why the flow finished. Did the messages simply run out, or do they need to re-authenticate and continue fetching messages? Now, I can think of two possible ways to handle it, neither of which I like.

Exceptions

An easy solution would be to signal the 'abnormal' flow termination with an exception, like this:

val messages = flow {
    while (source.hasNext()) {
        if (authentication.isExpired) {
            throw AuthenticationExpiredException()
        }
        emit(source.next())
    }
    println("Finished sending all the messages")
}

That's great, because now the consumer can use a try/catch to determine why the flow was terminated:

try {
    messages.collect { doSomethingWith(it) }
    println("There are no more messages")
} catch (e: AuthenticationExpiredException) {
    println("The auth token expired")
}

But using exceptions like this is absolutely not something I want to do. In his article on Kotlin and Exceptions, Kotlin libraries lead Roman Elizarov explicitly tells us:

don’t use exceptions to return a result value, [and] avoid try/catch in general application code.

It's a great article and there are many reasons I agree with it, but one critical reason for wanting to not use exceptions is that it introduces behaviour that isn't type checked. If I use my AuthenticationExpiredException, then without reading the code, the caller of the flow doesn't know that they have to catch and handle it. The code will compile just fine without the try/catch, and will fail at runtime. That's an intentional language design choice in Kotlin: exceptions aren't checked at compile time, because they're simply not intended to be used in this way.

Sealed classes

A great way to return different types of results elsewhere in Kotlin is to use sealed classes. So I might write something like this:

sealed class FlowResult<out T> {
    data class Value<T>(val value: T): FlowResult<T>
    object: NoMoreMessages: FlowResult<Nothing>()
    object: AuthenticationExpired: FlowResult<Nothing>()
}

val messages = flow {
    while(source.hasNext()) {
        if (authentication.isExpired) {
            emit(AuthenticationExpired)
            break
        }
        emit(Value(source.next()))
    }
    emit(NoMoreMessages)
}

This is much nicer, because now the two possible close reasons are treated equally. The consumer can use a when block to handle the different results, and will get a compilation error if they don't include a branch that handles AuthenticationExpired.

The problem I have with this approach is that I can't enforce the rule that the "failure" results must always be the final item in the flow. For example, say I made a mistake and accidentally missed out the break from my loop.

val messages = flow {
    while(source.hasNext()) {
        if (authentication.isExpired) {
            emit(AuthenticationExpired)
        }
        emit(Value(source.next()))
    }
    emit(NoMoreMessages)
}

Now I'll end up emitting the AuthenticationExpired value without actually terminating the flow. It'll just continue emitting messages. This could lead to all manner of bugs and security problems, but it will compile just fine.


It feels like what I'm really looking for is a mechanism whereby collect could return a value, like this:

val closeReason = messages.collect { doSomethingWith(it) }
when (closeReason) {
    ...
}

Is there a way I can achieve something like that? Or is there another approach that gets around the problems I've described?

Sam
  • 8,330
  • 2
  • 26
  • 51

1 Answers1

1

There isn't a built-in method of achieving what you want, but it is possible to build on top of the existing APIs and combine it with the sealed classes:

// Essentially a functional Either type
sealed class Signal<out V, out T> {
    data class Value<V>(val value: V): Signal<V, Nothing>
    data class Terminal<T>(val value: T): Signal<Nothing, T>
}

Then build a set of extension methods that do the processing you want

inline suspend fun <reified V, T> Flow<Signal<V,T>>.collect(
    valueCollector: suspend (V) -> Unit
): T = onEach {
    if (it is Signal.Value) {
        valueCollector(it.value)
    }
}.filterIsInstance<Signal.Terminal<T>>().first().value

This processed value items using the provided collector, and returns the value of the first Terminal instance it sees as a return value.

Kiskae
  • 24,655
  • 2
  • 77
  • 74