寫 Grimo 專案時,錯誤處理一直困擾著我。
有時候用 try-catch:
try {
val project = repository.getProject(id)
} catch (e: Exception) {
// 處理錯誤
}
有時候返回 null:
val project = repository.getProject(id)
if (project == null) {
// 處理找不到
}
有時候用 Kotlin 的 Result:
val result = runCatching { repository.getProject(id) }
三種方式混在一起,程式碼很亂。
而且聽說 KMP 如果要支援 iOS,Exception 的處理會有問題。
「請以 ARS 角色,研究 KMP 專案的錯誤處理最佳實踐,特別是跨平台相容性。」
ARS 的研究報告:
## 研究報告:KMP 錯誤處理方案
### 執行摘要
- iOS 互操作性問題:Exception 會變成 generic error
- 建議採用 Result Type Pattern
- 參考 Arrow-kt 的 Either 設計
### 發現的方案
1. **Arrow-kt Either** - 功能完整但較重
2. **Kotlin Result** - 官方但功能有限
3. **自訂 Result Type** - 輕量且可控
### 業界實踐
- Cash App:自訂 Result type
- Touchlab:避免 Exception,用 sealed class
「原來函數式程式設計有個概念叫 Result Type Pattern,」我理解了。
看了 ARS 的報告後,我問 Claude Code:「基於 ARS 的研究,幫我設計一個輕量的 AppResult。」
Claude Code 分析:「有幾個考量點:
「Result Type Pattern 很適合 KMP,」Claude Code 建議,「它把錯誤當作正常的返回值,而不是異常。」
根據 ARS 的研究,Arrow-kt 的 Either 概念很棒但太重了。
Claude Code 根據研究結果設計了輕量版:
sealed class AppResult<out T, out E : AppError> {
data class Success<T>(val value: T) : AppResult<T, Nothing>()
data class Failure<E : AppError>(val error: E) : AppResult<Nothing, E>()
}
簡單明瞭,Success 或 Failure。
接下來要定義錯誤類型。
「錯誤該怎麼分類?」我思考。
翻了翻專案,大概有這幾類:
開始寫 AppError:
sealed interface AppError {
val message: String
val isRecoverable: Boolean
data class Database(
override val message: String,
val operation: String? = null,
override val isRecoverable: Boolean = true
) : AppError
data class Network(
override val message: String,
val statusCode: Int? = null,
override val isRecoverable: Boolean = true
) : AppError
data class Validation(
val field: String,
val reason: String,
override val message: String = "$field: $reason",
override val isRecoverable: Boolean = false
) : AppError
data class Business(
val code: String,
override val message: String,
override val isRecoverable: Boolean = false
) : AppError
data class Unknown(
override val message: String,
override val isRecoverable: Boolean = false
) : AppError
}
看了函數式程式設計的資料,AppResult 需要一些基本操作:
// map - 轉換成功值
inline fun <T, E : AppError, R> AppResult<T, E>.map(
transform: (T) -> R
): AppResult<R, E> = when (this) {
is AppResult.Success -> AppResult.Success(transform(value))
is AppResult.Failure -> this
}
// flatMap - 鏈式操作
inline fun <T, E : AppError, R> AppResult<T, E>.flatMap(
transform: (T) -> AppResult<R, E>
): AppResult<R, E> = when (this) {
is AppResult.Success -> transform(value)
is AppResult.Failure -> this
}
// fold - 處理成功和失敗
inline fun <T, E : AppError, R> AppResult<T, E>.fold(
onSuccess: (T) -> R,
onFailure: (E) -> R
): R = when (this) {
is AppResult.Success -> onSuccess(value)
is AppResult.Failure -> onFailure(error)
}
最實用的是 catching,可以把 try-catch 轉成 AppResult:
companion object {
inline fun <T> catching(block: () -> T): AppResult<T, AppError> {
return try {
Success(block())
} catch (e: CancellationException) {
// 協程取消要繼續傳播
throw e
} catch (e: Exception) {
Failure(mapThrowableToError(e))
}
}
}
這個 CancellationException 的處理很重要,Claude Code 提醒我的。
不同平台的 Exception 不一樣,需要用 expect/actual:
// commonMain
expect fun mapThrowableToError(throwable: Throwable): AppError
// desktopMain (JVM)
actual fun mapThrowableToError(throwable: Throwable): AppError {
return when (throwable) {
is java.sql.SQLException -> {
// SQLDelight 會拋出這個
when {
throwable.message?.contains("locked") == true ->
AppError.Database(
message = "資料庫被鎖定",
isRecoverable = true
)
else -> AppError.Database(
message = throwable.message ?: "資料庫錯誤"
)
}
}
is IllegalArgumentException ->
AppError.Validation(
field = "unknown",
reason = throwable.message ?: "參數錯誤"
)
else -> AppError.Unknown(
message = throwable.message ?: "未知錯誤"
)
}
}
原本的 Repository:
suspend fun getProject(id: String): Project? {
return try {
db.getProject(id)
} catch (e: Exception) {
logger.error("Failed to get project", e)
null
}
}
問題是呼叫端不知道是「找不到」還是「出錯了」。
改用 AppResult:
suspend fun getProject(id: String): AppResult<Project, AppError> {
return AppResult.catching {
db.getProject(id)
}.flatMap { project ->
if (project != null) {
AppResult.Success(project)
} else {
AppResult.Failure(
AppError.Business(
code = "PROJECT_NOT_FOUND",
message = "專案不存在"
)
)
}
}
}
現在錯誤類型很清楚。
ViewModel 裡可以用 fold 處理結果:
fun loadProject(id: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
repository.getProject(id).fold(
onSuccess = { project ->
_uiState.update {
it.copy(
isLoading = false,
project = project,
error = null
)
}
},
onFailure = { error ->
_uiState.update {
it.copy(
isLoading = false,
error = error.message
)
}
handleError(error)
}
)
}
}
private fun handleError(error: AppError) {
when (error) {
is AppError.Network -> {
if (error.isRecoverable) {
// 排程重試
}
}
is AppError.Business -> {
if (error.code == "PROJECT_NOT_FOUND") {
// 導航到建立專案
}
}
else -> {
// 顯示錯誤
}
}
}
每種錯誤都有對應的處理方式。
網路錯誤經常需要重試,我加了個 retry 函數:
suspend fun <T> AppResult.Companion.retry(
times: Int = 3,
initialDelay: Long = 100,
block: suspend () -> AppResult<T, AppError>
): AppResult<T, AppError> {
var currentDelay = initialDelay
repeat(times - 1) {
val result = block()
if (result.isSuccess) return result
// 只重試可復原的錯誤
val error = result.errorOrNull()
if (error != null && !error.isRecoverable) {
return result
}
delay(currentDelay)
currentDelay *= 2 // exponential backoff
}
return block() // 最後一次
}
使用起來很方便:
val result = AppResult.retry {
repository.syncWithServer()
}
最大的轉變是思維:錯誤是正常的程式流程,不是異常情況。
用 AppResult 後,錯誤處理變得很自然,編譯器會提醒你處理所有情況。
Sealed class 確保錯誤分類完整,when 表達式會檢查所有分支。
不會再有「忘記處理某種錯誤」的問題。
雖然現在只做 Desktop,但提前考慮 iOS 相容性,未來會輕鬆很多。
AppResult 模式在 Swift 也很自然,iOS 開發者會喜歡的。
今天深入研究並實作了 AppResult 錯誤處理系統。
從混亂的 try-catch 和 null 檢查,到統一的 Result Type Pattern,程式碼變得更清晰、更安全。
Claude Code 在這個過程中幫了很多忙,特別是提醒我注意 CancellationException 和 iOS 相容性問題。
最大的收穫是理解了函數式錯誤處理的優勢。錯誤不再是意外,而是程式邏輯的一部分。
明天會繼續探索其他架構改進。
「最好的錯誤處理,是讓錯誤成為類型系統的一部分。」
關於作者:Sam,一人公司創辦人。正在打造 Grimo,智能任務管理平台。
專案連結:GitHub - grimostudio