Inline Functions & catchingSuspend
Understanding inline functions and avoiding common pitfalls with catchingSuspend.
The Problemβ
You might encounter this confusing error when using catchingSuspend:
// β COMPILE ERROR: 'return' is not allowed here
fun processData(): SpiceResult<Data> {
return SpiceResult.catchingSuspend {
if (condition) {
return SpiceResult.failure(error) // β Error here!
}
fetchData()
}
}
Error message:
'return' is not allowed here
return is not allowed here for inlineable lambda parameter
Why This Happensβ
Inline Functions Explainedβ
catchingSuspend is defined as an inline function:
// From SpiceResult.kt
suspend inline fun <T> catchingSuspend(
crossinline block: suspend () -> T
): SpiceResult<T> = try {
Success(block())
} catch (e: Exception) {
Failure(SpiceError.fromException(e)) as SpiceResult<T>
}
What inline means:
- Function code is copied directly into the call site at compile time
- No function call overhead
- But: Changes how
returnbehaves!
Non-Local Returnsβ
In regular (non-inline) functions, return exits the lambda:
// Regular function
fun example() {
list.forEach { item ->
if (item == 5) return@forEach // Exits lambda only
}
println("This still prints")
}
In inline functions, return exits the enclosing function:
// Inline function (like forEach)
fun example() {
list.forEach { item -> // forEach is inline
if (item == 5) return // Exits example(), not just lambda!
}
println("This NEVER prints if item == 5")
}
Why It Breaks try-catchβ
// What you write:
return SpiceResult.catchingSuspend {
if (condition) return SpiceResult.failure(error)
fetchData()
}
// What actually happens after inlining:
return try {
if (condition) return SpiceResult.failure(error) // Returns from outer function!
Success(fetchData()) // Never reached
} catch (e: Exception) {
Failure(SpiceError.fromException(e))
}
The return escapes the try-catch block entirely, defeating its purpose!
Solutionsβ
Solution 1: Guard Clause (Recommended)β
Move the condition outside the inline function:
// β
Good - Clean and readable
fun processData(): SpiceResult<Data> {
if (condition) {
return SpiceResult.failure(error)
}
return SpiceResult.catchingSuspend {
fetchData()
}
}
Why this works:
- No
returninside the inline function - Early return pattern is clear
- try-catch wraps only the actual operation
Solution 2: Return Expressionβ
Use an if expression that returns a SpiceResult:
// β
Good - Functional style
fun processData(): SpiceResult<Data> {
return if (condition) {
SpiceResult.failure(error)
} else {
SpiceResult.catchingSuspend {
fetchData()
}
}
}
Why this works:
- No
returninside the lambda - The
ifexpression evaluates to aSpiceResult
Solution 3: flatMap Chainβ
Use flatMap for sequential validation:
// β
Good - Railway-oriented programming
fun processData(): SpiceResult<Data> {
return validateInput()
.flatMap { input ->
SpiceResult.catchingSuspend {
fetchData(input)
}
}
}
private fun validateInput(): SpiceResult<Input> {
return if (condition) {
SpiceResult.failure(error)
} else {
SpiceResult.success(input)
}
}
Why this works:
- Validation is separate from exception handling
- Clean pipeline of operations
- Each step returns a
SpiceResult
Solution 4: Nested Resultβ
Return a SpiceResult inside the lambda:
// β
Works but verbose
fun processData(): SpiceResult<Data> {
return SpiceResult.catchingSuspend {
if (condition) {
throw IllegalArgumentException("Invalid input")
}
fetchData()
}
}
Why this works:
- Throw an exception instead of returning
catchingSuspendcatches it and wraps inSpiceResult- More verbose than guard clause
Real-World Examplesβ
Example 1: Input Validationβ
// β Bad - Won't compile
suspend fun fetchUser(id: String): SpiceResult<User> {
return SpiceResult.catchingSuspend {
if (id.isBlank()) {
return SpiceResult.failure( // β Error!
SpiceError.validationError("ID is required")
)
}
apiClient.getUser(id)
}
}
// β
Good - Guard clause
suspend fun fetchUser(id: String): SpiceResult<User> {
if (id.isBlank()) {
return SpiceResult.failure(
SpiceError.validationError("ID is required")
)
}
return SpiceResult.catchingSuspend {
apiClient.getUser(id)
}
}
Example 2: Conditional API Callβ
// β Bad - Won't compile
suspend fun getData(useCache: Boolean): SpiceResult<Data> {
return SpiceResult.catchingSuspend {
if (useCache) {
val cached = cache.get()
if (cached != null) {
return SpiceResult.success(cached) // β Error!
}
}
apiClient.fetchData()
}
}
// β
Good - Early returns outside
suspend fun getData(useCache: Boolean): SpiceResult<Data> {
if (useCache) {
val cached = cache.get()
if (cached != null) {
return SpiceResult.success(cached)
}
}
return SpiceResult.catchingSuspend {
apiClient.fetchData()
}
}
Example 3: Multi-Step Validationβ
// β Bad - Multiple returns won't work
suspend fun createOrder(order: Order): SpiceResult<Order> {
return SpiceResult.catchingSuspend {
if (order.items.isEmpty()) {
return SpiceResult.failure(error1) // β Error!
}
if (order.total < 0) {
return SpiceResult.failure(error2) // β Error!
}
if (!order.hasValidPayment()) {
return SpiceResult.failure(error3) // β Error!
}
database.insertOrder(order)
}
}
// β
Good - Validation before catchingSuspend
suspend fun createOrder(order: Order): SpiceResult<Order> {
// Validate first
if (order.items.isEmpty()) {
return SpiceResult.failure(
SpiceError.validationError("Order must have items")
)
}
if (order.total < 0) {
return SpiceResult.failure(
SpiceError.validationError("Total cannot be negative")
)
}
if (!order.hasValidPayment()) {
return SpiceResult.failure(
SpiceError.validationError("Invalid payment method")
)
}
// Then catch exceptions
return SpiceResult.catchingSuspend {
database.insertOrder(order)
}
}
// β
Better - Use validation helper
suspend fun createOrder(order: Order): SpiceResult<Order> {
return validateOrder(order)
.flatMap { validOrder ->
SpiceResult.catchingSuspend {
database.insertOrder(validOrder)
}
}
}
private fun validateOrder(order: Order): SpiceResult<Order> {
return when {
order.items.isEmpty() -> SpiceResult.failure(
SpiceError.validationError("Order must have items")
)
order.total < 0 -> SpiceResult.failure(
SpiceError.validationError("Total cannot be negative")
)
!order.hasValidPayment() -> SpiceResult.failure(
SpiceError.validationError("Invalid payment method")
)
else -> SpiceResult.success(order)
}
}
catching vs catchingSuspendβ
Both functions are inline, so the same rules apply:
// Synchronous version
inline fun <T> catching(block: () -> T): SpiceResult<T>
// Asynchronous version
suspend inline fun <T> catchingSuspend(crossinline block: suspend () -> T): SpiceResult<T>
When to use each:
// β
Use catching for synchronous operations
val result = SpiceResult.catching {
parseJson(jsonString)
}
// β
Use catchingSuspend for suspend operations
val result = SpiceResult.catchingSuspend {
fetchFromAPI()
}
// β Don't use catching for suspend functions
val result = SpiceResult.catching {
fetchFromAPI() // β Won't compile - catching is not suspend
}
// β Don't use catchingSuspend for sync operations (works but unnecessary)
val result = SpiceResult.catchingSuspend {
parseJson(jsonString) // β οΈ Works but use catching instead
}
Pattern: Validation + Exception Handlingβ
The best pattern is to separate validation from exception handling:
// β
Excellent pattern
suspend fun processRequest(request: Request): SpiceResult<Response> {
// Step 1: Validate (business logic errors)
return validateRequest(request)
.flatMap { validRequest ->
// Step 2: Execute (technical errors)
SpiceResult.catchingSuspend {
executeRequest(validRequest)
}
}
.mapError { error ->
// Step 3: Enrich with context
error.withContext(
"request_id" to request.id,
"user_id" to request.userId
)
}
}
private fun validateRequest(request: Request): SpiceResult<Request> {
// Validation logic - returns specific errors
return when {
request.userId.isBlank() ->
SpiceResult.failure(SpiceError.validationError("User ID required"))
request.amount < 0 ->
SpiceResult.failure(SpiceError.validationError("Amount must be positive"))
else ->
SpiceResult.success(request)
}
}
private suspend fun executeRequest(request: Request): Response {
// Actual operation - throws exceptions
return apiClient.call(request)
}
Why this pattern is excellent:
- Clear separation - Validation vs exception handling
- Type-safe - Validation never throws
- Testable - Easy to test validation separately
- Maintainable - Each function has single responsibility
- Composable - Easy to chain with
flatMap
Common Pitfallsβ
Pitfall 1: Nested catchingSuspendβ
// β Bad - Nested catchingSuspend
val result = SpiceResult.catchingSuspend {
val user = SpiceResult.catchingSuspend { // β Unnecessary nesting
fetchUser(id)
}.getOrThrow()
processUser(user)
}
// β
Good - Use flatMap
val result = SpiceResult.catchingSuspend {
fetchUser(id)
}.flatMap { user ->
SpiceResult.catchingSuspend {
processUser(user)
}
}
Pitfall 2: Returning null Instead of Resultβ
// β Bad - Returns null, losing error information
suspend fun fetchUser(id: String): User? {
return SpiceResult.catchingSuspend {
apiClient.getUser(id)
}.getOrNull() // β Lost error details!
}
// β
Good - Returns Result with error
suspend fun fetchUser(id: String): SpiceResult<User> {
return SpiceResult.catchingSuspend {
apiClient.getUser(id)
}
}
Pitfall 3: Catching Too Muchβ
// β Bad - Catches validation errors as exceptions
suspend fun createUser(email: String): SpiceResult<User> {
return SpiceResult.catchingSuspend {
// Business logic errors should not throw!
if (!email.contains("@")) {
throw IllegalArgumentException("Invalid email") // β Bad practice
}
database.insertUser(User(email))
}
}
// β
Good - Validate explicitly
suspend fun createUser(email: String): SpiceResult<User> {
if (!email.contains("@")) {
return SpiceResult.failure(
SpiceError.validationError("Invalid email format")
)
}
return SpiceResult.catchingSuspend {
database.insertUser(User(email))
}
}
Pitfall 4: Forgetting await() in Asyncβ
// β Bad - Returns Deferred, not the actual result
suspend fun fetchData(): SpiceResult<Data> {
return SpiceResult.catchingSuspend {
coroutineScope {
async {
apiClient.fetch()
} // β Returns Deferred<Data>, not Data!
}
}
}
// β
Good - await() the result
suspend fun fetchData(): SpiceResult<Data> {
return SpiceResult.catchingSuspend {
coroutineScope {
async {
apiClient.fetch()
}.await() // β
Returns Data
}
}
}
Advanced: Custom Inline Result Functionsβ
You can create your own inline functions that return SpiceResult:
// Custom inline function for retry logic
suspend inline fun <T> retryWithResult(
maxAttempts: Int = 3,
crossinline block: suspend () -> T
): SpiceResult<T> {
var lastError: Throwable? = null
repeat(maxAttempts) { attempt ->
try {
return SpiceResult.success(block())
} catch (e: Exception) {
lastError = e
if (attempt < maxAttempts - 1) {
delay(1000L * (attempt + 1))
}
}
}
return SpiceResult.failure(
SpiceError.networkError(
"Failed after $maxAttempts attempts",
cause = lastError
)
)
}
// Usage - same return rules apply!
suspend fun fetchWithRetry(): SpiceResult<Data> {
// β
Good - No return inside
return retryWithResult {
apiClient.fetch()
}
// β Bad - Return inside inline function
return retryWithResult {
if (cache.hasData()) {
return SpiceResult.success(cache.get()) // β Won't work!
}
apiClient.fetch()
}
}
Testing Inline Functionsβ
Inline functions work normally in tests:
@Test
fun `should catch exceptions`() = runTest {
val result = SpiceResult.catchingSuspend {
throw RuntimeException("Test error")
}
assertTrue(result.isFailure)
assertEquals("Test error", (result as SpiceResult.Failure).error.message)
}
@Test
fun `should return success`() = runTest {
val result = SpiceResult.catchingSuspend {
"success"
}
assertTrue(result.isSuccess)
assertEquals("success", result.getOrNull())
}
@Test
fun `validation should happen outside catchingSuspend`() = runTest {
suspend fun process(value: Int): SpiceResult<String> {
if (value < 0) {
return SpiceResult.failure(
SpiceError.validationError("Must be positive")
)
}
return SpiceResult.catchingSuspend {
"Processed: $value"
}
}
// Test validation
val invalid = process(-1)
assertTrue(invalid.isFailure)
// Test success
val valid = process(10)
assertTrue(valid.isSuccess)
assertEquals("Processed: 10", valid.getOrNull())
}
Summaryβ
Key Takeawaysβ
- β
Inline functions paste code at call site - changes how
returnworks - β
Never
returninsidecatchingSuspendorcatching- breaks try-catch - β Use guard clauses to return early before the inline function
- β Separate validation (business logic) from exception handling (technical errors)
- β
Use
flatMapto chain operations that returnSpiceResult - β
Choose
catchingfor sync,catchingSuspendfor async
Quick Referenceβ
// β DON'T: Return inside inline function
SpiceResult.catchingSuspend {
if (condition) return SpiceResult.failure(error) // β
doWork()
}
// β
DO: Guard clause before inline function
if (condition) {
return SpiceResult.failure(error)
}
return SpiceResult.catchingSuspend {
doWork()
}
// β
DO: If expression
return if (condition) {
SpiceResult.failure(error)
} else {
SpiceResult.catchingSuspend { doWork() }
}
// β
DO: Validation chain
validateInput()
.flatMap { input ->
SpiceResult.catchingSuspend { doWork(input) }
}
Next Stepsβ
- SpiceResult Guide - Complete SpiceResult API
- Best Practices - Advanced error handling patterns
- Error Types - All SpiceError types