Result — flatMapError, recover và recoverWith
Overview
Khi chain computation với Result<A, E> hoặc Either<E, A>, ta có thể xem Success/Right là nhánh chính, còn Failure/Left là nhánh short-circuit. Một implementation của Either mà map và flatMap mặc định vận hành trên Right được gọi là right-biased; với Result, ta cũng có thể mô tả tương tự là success-biased. Chú ý rằng đây chỉ là một lựa chọn thiết kế của implementation, chứ không phải một thuộc tính bắt buộc của mọi Result/Either.
Vì vậy, callback truyền vào các success-side combinator như map và flatMap chỉ được gọi trên Success/Right; nếu là Failure/Left, callback sẽ không được gọi và giá trị Failure/Left đó sẽ được trả về.
Còn trường hợp muốn xử lý nhánh Failure/Left thì sao? Để map phía error thì ta có mapError, còn để tiếp tục computation phía error thì ta có flatMapError. Hai combinator này đối xứng với map và flatMap.
Bài viết này tổng hợp tên gọi và semantics của flatMapError/recover/recoverWith trong một số ngôn ngữ và thư viện phổ biến, đồng thời rút ra một mental model cốt lõi để thiết kế API cho custom Result/Either type.
Một use case thực tế: API → cache → UI
Trước khi đi vào type signature, ta hãy xem một flow load user điển hình trong một ứng dụng mobile, ở đây ta dùng Kotlin để minh họa. API có thể fail vì mất mạng, hết session hoặc một lỗi không mong muốn. Nếu mất mạng, ta thử fallback sang cache, nếu vẫn không có dữ liệu ở cache, thì error còn lại được chuyển thành một UI state phù hợp.
Ví dụ dưới đây lược bỏ implementation của userApi, userCache và các UI model để tập trung vào pipeline:
// --- Domain / application layer ---
sealed interface LoadUserError {
data object Offline : LoadUserError
data object Unauthorized : LoadUserError
data object CacheMiss : LoadUserError
data class Unexpected(val cause: Throwable) : LoadUserError
}
// --- Data layer (repository implementation) ---
fun loadUser(userId: UserId): Result<User, LoadUserError> =
userApi
.fetchUser(userId) // Result<User, ApiError>
.mapError(::apiErrorToLoadUserError) // Result<User, LoadUserError>
.flatMapError { error ->
when (error) {
LoadUserError.Offline ->
userCache
.readUser(userId) // Result<User, CacheError>
.mapError(::cacheErrorToLoadUserError) // Result<User, LoadUserError>
else -> Result.Err(error)
}
}
// --- Presentation layer ---
private fun userToUiModel(user: User): UserUiModel =
UserUiModel.Content(user)
private fun errorToUiModel(error: LoadUserError): UserUiModel =
when (error) {
LoadUserError.Unauthorized -> UserUiModel.SignInRequired
LoadUserError.Offline,
LoadUserError.CacheMiss -> UserUiModel.Offline
is LoadUserError.Unexpected -> UserUiModel.Error(error.cause)
}
fun loadUserScreen(userId: UserId): Result<UserUiModel, Nothing> =
loadUser(userId)
.map(::userToUiModel)
.recover(::errorToUiModel)
Flow trên cho thấy vai trò khác nhau của từng operation:
mapErrorbiến đổiApiError/CacheErrorthành domain errorLoadUserError.flatMapErrorchỉ chạy khi API fail. Handler có thể đưa pipeline trở lại success channel bằng cachedUser, hoặc tiếp tục error channel bằng error ban đầu hay mộtLoadUserErrormới từ cache.mapđổiUserthànhUserUiModel.Contentkhi pipeline thành công.recoverbiến error cuối cùng thành mộtUserUiModel, nên typed error channel của kết quả trở thànhNothing.
Use case này cũng cho thấy flatMapError không chỉ có nghĩa là “trả một fallback value”.
Handler của nó trả về một Result mới, vì vậy nó có thể recover thành công hoặc tiếp tục thất bại.
Đây chính là điểm khác biệt cốt lõi giữa flatMapError và recover.
I. Mental model cốt lõi
Với kiểu:
Result<A, E>
Trong đó A là type của success value, còn E là type của error value. Nếu chỉ nhìn vào type của callback mà mỗi combinator nhận, ta có bốn phép biến đổi đối xứng tự nhiên:
map : (A) -> B
flatMap : (A) -> Result<B, E>
mapError : (E) -> F
flatMapError : (E) -> Result<A, F>
Có thể hiểu ngắn gọn:
map = biến đổi success value, giữ nguyên error type
flatMap = tiếp tục success computation, giữ nguyên error type
mapError = biến đổi error value, giữ nguyên success type
flatMapError = tiếp tục error computation, giữ nguyên success type
Ta thấy rằng mapError và flatMapError là đối xứng với map và flatMap. Cả bốn combinator đều giữ nguyên một channel (success hoặc error) và chỉ biến đổi channel còn lại.
II. Kotlin
Một implementation tối giản với sealed interface:
sealed interface Result<out A, out E> {
data class Ok<out A>(val value: A) : Result<A, Nothing>
data class Err<out E>(val error: E) : Result<Nothing, E>
}
/**
* Maps the success value of this Result to a new value.
*/
inline fun <A, E, B> Result<A, E>.map(
transform: (value: A) -> B,
): Result<B, E> =
when (this) {
is Result.Ok -> Result.Ok(transform(value))
is Result.Err -> this
}
/**
* Continues the computation with a new Result on the success value,
* keeping the error type unchanged.
*/
inline fun <A, E, B> Result<A, E>.flatMap(
transform: (value: A) -> Result<B, E>,
): Result<B, E> =
when (this) {
is Result.Ok -> transform(value)
is Result.Err -> this
}
/**
* Transforms the error value of this Result to a new value.
*/
inline fun <A, E, F> Result<A, E>.mapError(
transform: (error: E) -> F,
): Result<A, F> =
when (this) {
is Result.Ok -> this
is Result.Err -> Result.Err(transform(error))
}
/**
* Continues the computation with a new Result on the error value,
* keeping the success type unchanged.
*/
inline fun <A, E, F> Result<A, E>.flatMapError(
transform: (error: E) -> Result<A, F>,
): Result<A, F> =
when (this) {
is Result.Ok -> this
is Result.Err -> transform(error)
}
recover có thể được dễ dàng xây dựng dựa trên flatMapError:
inline fun <A, E> Result<A, E>.recover(
transform: (error: E) -> A,
): Result<A, Nothing> =
flatMapError { error -> Result.Ok(transform(error)) }
recover có kiểu trả về Result<A, Nothing> diễn đạt rằng toàn bộ typed error E đã được xử lý. Điều này chỉ nói về error channel của Result; transform vẫn có thể ném exception. Implementation ở trên không catch exception đó, và Kotlin xem mọi exception đều là unchecked.
Tới đây, ta thấy thêm một điều là recover và map đều có thể diễn đạt bằng flatMapError và flatMap, cụ thể:
recover(f) = flatMapError { error -> Result.Ok(f(error)) }
map(f) = flatMap { value -> Result.Ok(f(value)) }
Với custom typed Result<A, E>, nếu ưu tiên symmetry giữa hai channel thì một bộ tên nhất quán và dễ nhớ là: map, flatMap, mapError, flatMapError. Còn recover là một convenience operation.
Sơ đồ dưới đây tóm tắt cách 5 operation trên vận hành giữa “success channel” và “error channel”.
Điểm bắt đầu của các arrow cho biết channel nào kích hoạt callback; các arrowhead cho biết Result mới có thể kết thúc ở channel nào. recover ở đây là operation của custom Result ở trên và xử lý mọi error value E.
III. Scala
Scala thường dùng cặp:
recover
recoverWith
Với Try data type, ta có hai ví dụ sau:
val recovered: Try[Int] =
attempt.recover {
case _: IOException => 0
}
val recoveredWith: Try[Int] =
attempt.recoverWith {
case _: IOException => loadFromFallback()
}
Lược bỏ các capture-set annotations đang xuất hiện trong Scala 3 source code (xem Try.scala#L138-L143), ta có sơ lược như sau:
sealed abstract class Try[+T] extends Product with Serializable { self: Try[T] =>
/** Applies the given function `f` if this is a `Failure`, otherwise returns this if this is a `Success`.
* This is like `flatMap` for the exception.
*/
def recoverWith[U >: T](pf: PartialFunction[Throwable, Try[U]]): Try[U]
/** Applies the given function `f` if this is a `Failure`, otherwise returns this if this is a `Success`.
* This is like map for the exception.
*/
def recover[U >: T](pf: PartialFunction[Throwable, U]): Try[U]
}
Trong đó, U >: T cho phép success type được widen. Cả hai method chỉ áp dụng PartialFunction khi Try là Failure và exception nằm trong domain của PartialFunction. Nếu PartialFunction không được định nghĩa cho exception đó thì Failure ban đầu được giữ nguyên.
Về mặt API, PartialFunction#isDefinedAt cho biết một input có nằm trong domain hay không. Tuy nhiên, implementation của Try dùng applyOrElse để apply handler hoặc chọn fallback trong một bước.
Về mặt semantics, recover có thể được diễn đạt thông qua recoverWith tương tự ví dụ bằng Kotlin ở trên:
recover(pf) =
recoverWith(pf.andThen(value => Success(value)))
Ở đây, pf biến đổi Throwable thành U; PartialFunction#andThen tiếp tục biến đổi output U thành Success[U]. Vì function truyền vào andThen (value => Success(value)) là một total function, nên PartialFunction kết quả giữ nguyên domain của pf.
Ta thấy rõ recoverWith chính là error-side flatMap cho Try, nhưng chỉ trên những exception mà PartialFunction định nghĩa. Như docs của Try cũng nói rõ, các combinator của Try chỉ bắt non-fatal exceptions; serious system errors vẫn luôn được ném ra ngoài.
Với Either data type, flatMapError có thể được định nghĩa dựa trên fold như sau:
def flatMapError[A, E, F](
either: Either[E, A]
)(
transform: E => Either[F, A]
): Either[F, A] =
either.fold(
transform,
Right(_)
)
Tới đây, ta thấy naming convention trong cặp API này: hậu tố With cho biết handler tự trả về một Try[U]. Nếu không có With, handler trả về một unwrapped value U, rồi recover bọc value đó trong Success.
Nếu ta thiết kế một API Result/Either tổng quát và ưu tiên symmetry trực tiếp với flatMap, thì tên flatMapError rõ ràng và phù hợp hơn.
IV. Haskell
Haskell thường không dùng tên recoverWith.
Operation có semantics gần nhất là catchError, nhưng nó chỉ tương đương với flatMapError khi error type được giữ nguyên, bởi vì catchError không cho phép đổi error type.
Cụ thể, MonadError typeclass và các method của nó được khai báo như sau:
class (Monad m) => MonadError e m | m -> e where
throwError :: e -> m a
catchError :: m a -> (e -> m a) -> m a
Nếu bạn nào chưa quen Haskell, thì có thể đọc hiểu declaration này như sau:
-
MonadError e mlà constraint cho biết type constructormvừa có instance củaMonad, vừa hỗ trợ error typeethông quaMonadError. Một điểm quan trọng làevàmở các input, các handler và các output đều giống nhau. Điều đó bị ràng buộc bởi Functional dependencym -> etrongMonadError e m | m -> e, có ý nghĩa như sau: nếu ta biết type constructormthì ta xác định chính xác một error typee, nêncatchErrorcũng nhưthrowErrorkhông thể đổiethành một error type khác. -
m alà một computation trong contextm, có thể thành công với success valueahoặc thất bại với error valuee. Không nên hiểu một cách tổng quát rằngm aluôn là một container chứa sẵnahoặce. -
throwError :: e -> m atạo một failing computation trong contextmtừ error valuee. Chú ý rằng “throw” ở đây không nhất thiết là ném runtime exception, vì semantics cụ thể phụ thuộc vào từngMonadErrorinstance. Ví dụ vớiEither e, ta cóm = Either evàthrowError error = Left error. -
Với
catchError :: m a -> (e -> m a) -> m a, parametere -> m alà 1 handler nhận error valueevà trả về một computation mới trong cùng contextm. Handler có thể recover bằng một success value hoặc tiếp tục fail, nhưng vẫn cùng error typee. Nhìn rõ hơn dưới dạngcatchError action handler, nếuactionthất bại theo semantics củaMonadError e mthìcatchErrorsẽ gọi handler. Nếuactionthành công thìcatchErrorbỏ qua handler và trả về kết quả đó.
Một recover trả plain value có thể được xây bằng pure tương tự như recover trong Kotlin:
recover
:: MonadError e m
=> m a
-> (e -> a)
-> m a
recover action transform =
catchError action (pure . transform)
Ở đây, pure . transform tương đương với \error -> pure (transform error): transform tạo plain value a, sau đó pure lift value đó vào context m a.
Với concrete type Either, ta có thể viết một flatMapError tổng quát hơn và cho phép đổi error type:
flatMapError
:: (e -> Either f a)
-> Either e a
-> Either f a
flatMapError transform =
either transform Right
Chú ý rằng either :: (a -> c) -> (b -> c) -> Either a b -> c đóng vai trò tương tự Either.fold trong ví dụ Scala ở trên.
Cuối cùng, ta thấy mental model vẫn giống nhau:
catchError = error-side flatMap, nhưng giữ nguyên error type e
flatMapError = error-side flatMap tổng quát, có thể đổi error type e thành f
recover = handler trả a rồi được nâng bằng pure
V. Kết luận
-
Nếu ưu tiên symmetry trực tiếp với
flatMap,flatMapErrorlà một lựa chọn phù hợp cho custom KotlinResult<A, E>vì nó:- đối xứng trực tiếp với
flatMap, và mô tả đúng việc callback trả về mộtResultmới khi có error, nên nó dễ nhớ và dễ hiểu. - không giới hạn operation vào riêng hành vi recovery, vì nó có thể làm nhiều hơn là chỉ recovery.
- không phụ thuộc vào convention của Scala như hậu tố
With.
- đối xứng trực tiếp với
-
Bộ API cuối cùng:
map,flatMap,mapError,flatMapError. Cònrecoverchỉ là convenience operation:recover(f) = flatMapError { e -> Result.Ok(f(e)) }.