Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I transform a signal with errors into a NoError one with ReactiveSwift? (and be elegant)

What is the most elegant way to transform my ReactiveSwift's SignalProducer<A, NetworkError> into a Signal<A, NoError>?

Most of the time, my signal producer is the result of a network call, so I want to split the results into two cases:

  • if a value is available, send a Signal<A, NoError>
  • if an error happened, send a Signal<String, NoError> with the error's localized description

(why? because i'm trying to be as MVVM as possible)

So far, I end up writing a lot of boilerplate like the following:

let resultsProperty = MutableProperty<SearchResults?>(nil)
let alertMessageProperty = MutableProperty<String?>(nil)

let results = resultsProperty.signal // `Signal<SearchResults?, NoError>`
let alertMessage = alertMessageProperty.signal // `Signal<String?, NoError>`

// ...

searchStrings.flatMap(.latest) { string -> SignalProducer<SearchResults, NetworkError> in
        return MyService.search(string)
}
.observe { event in 
    switch event {
        case let .value(results):
            resultsProperty.value = results

        case let .failed(error):
            alertMessageProperty.value = error

        case .completed, .interrupted:
            break
    }
}

ie:

  1. using MutableProperty instances, that I have to set as optional to be able to initialize them
  2. creating signals from those, ie getting a signal sending optionals as well
  3. it feels dirty and makes the code so intertwined it kind of ruins the point of being reactive

Any help on (A) keeping my signals non optional and (B) splitting them into 2 NoError signals elegantly would be greatly appreciated.

like image 828
Dirty Henry Avatar asked Mar 07 '23 09:03

Dirty Henry


1 Answers

Edit - Second Attempt

I will try to answer all your questions / comments here.

The errors = part doesn't work as flatMapError expects a SignalProducer (ie your sample code works just because searchStrings is a Signal string, which coincidently is the same as the one we want for errors: it does not work for any other kind of input)

You are correct, this is because flatMapError does not change the value type. (Its signature is func flatMapError<F>(_ transform: @escaping (Error) -> SignalProducer<Value, F>) -> SignalProducer<Value, F>). You could add another call to map after this if you need to change it into another value type.

the results = part behaves weirdly as it terminates the signal as soon as an error is met (which is a behavior I don't want) in my real-life scenario

Yes, this is because the flatMap(.latest) forwards all errors to the outer signal, and any error on the outer signal will terminate it.

Okay so here's an updated version of the code, with the extra requirements that

  1. errors should have different type than searchStrings, let's say Int
  2. Any error from MyService.search($0) will not terminate the flow

I think the easiest way to tackle both these issues is with the use of materialize(). What it does is basically "wrap" all signal events (new value, error, termination) into a Event object, and then forward this object in the signal. So it will transform a signal of type Signal<A, Error> into a Signal<Event<A, Error>, NoError> (you can see that the returned signal does not have an error anymore, since it is wrapped in the Event).

What it means in our case is that you can use that to easily prevent signals from terminating after emitting errors. If the error is wrapped inside an Event, then it will not automatically terminate the signal who sends it. (Actually, only the signal calling materialize() completes, but we will wrap it inside the flatMap so the outer one should not complete.)

Here's how it looks like:

// Again, I assume this is what you get from the user
let searchStrings: Signal<String, NoError>

// Keep your flatMap
let searchResults = searchStrings.flatMap(.latest) {
    // Except this time, we wrap the events with `materialize()`
    return MyService.search($0).materialize()
}

// Now Since `searchResults` is already `NoError` you can simply
// use `filterMap` to filter out the events that are not `.value`
results = searchResults.filterMap { (event) in
    // `event.value` will  return `nil` for all `Event` 
    // except `.value(T)` where it returns the wrapped value
    return event.value
}

// Same thing for errors
errors = searchResults.filterMap { (event) in
    // `event.error` will  return `nil` for all `Event` 
    // except `.failure(Error)` where it returns the wrapped error
    // Here I use `underestimatedCount` to have a mapping to Int
    return event.error?.map { (error) in 
        // Whatever your error mapping is, you can return any type here
        error.localizedDescription.characters.count
    }
}

Let me know if that helps! I actually think it looks better than the first attempt :)


First Attempt

Do you need to access the state of you viewModel or are you trying to go full state-less? If state-less, you don't need any properties, and you can just do

// I assume this is what you get from the user
let searchStrings: Signal<String, NoError>

// Keep your flatMap
let searchResults = searchStrings.flatMap(.latest) {
    return MyService.search($0)
}

// Use flatMapError to remove the error for the values
results = searchResults.flatMapError { .empty }

// Use flatMap to remove the values and keep the errors
errors = searchResults.filter { true }.flatMapError { (error) in
    // Whatever you mapping from error to string is, put it inside
    // a SignalProducer(value:)
    return SignalProducer(value: error.localizedDescription)
}
like image 172
Julien Perrenoud Avatar answered Mar 15 '23 23:03

Julien Perrenoud