Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Functional Error Handling In Kotlin - A Practical Approach

Bas
June 14, 2022

Functional Error Handling In Kotlin - A Practical Approach

Bas

June 14, 2022
Tweet

Other Decks in Technology

Transcript

  1. Bas de Groot - June 2022 - Kotlin Meetup Amsterdam

    Functional Error Handling A practical approach
  2. @basdgrt Relative cost to fi x bugs Based on the

    time of detection 0 10 20 30 Coding Automated testing Acceptance testing Production
  3. @basdgrt Compile time safety Compiler can guarantee certain errors are

    not present Pair programming with someone who never makes a mistake
  4. @basdgrt Compile time safety Compiler can guarantee certain errors are

    not present Pair programming with someone who never makes a mistake Kotlin offers some amazing features
  5. @basdgrt Bugs spotted at compile time Are easy and cheap

    to fi x Will never have any business impact
  6. @basdgrt Bugs spotted at compile time Are easy and cheap

    to fi x Will never have any business impact But… This only works if we design our code in the correct way
  7. @basdgrt Designing for compile time safety enum class Animal {

    CAT, DOG, LION } fun isPet(animal: Animal): Boolean { if (animal == Animal.CAT || animal == Animal.DOG) { return true } return false }
  8. @basdgrt Designing for compile time safety enum class Animal {

    CAT, DOG, LION } fun isPet(animal: Animal): Boolean { if (animal == Animal.CAT || animal == Animal.DOG) { return true } return false } Add GOLDFISH
  9. @basdgrt Designing for compile time safety enum class Animal {

    CAT, DOG, LION } fun isPet(animal: Animal): Boolean { if (animal == Animal.CAT || animal == Animal.DOG) { return true } return false } fun isPet(animal: Animal) = when (animal) { Animal.CAT, Animal.DOG -> true Animal.LION -> false } Add GOLDFISH
  10. @basdgrt Designing for compile time safety enum class Animal {

    CAT, DOG, LION } fun isPet(animal: Animal): Boolean { if (animal == Animal.CAT || animal == Animal.DOG) { return true } return false } fun isPet(animal: Animal) = when (animal) { Animal.CAT, Animal.DOG -> true Animal.LION -> false } Add GOLDFISH Compile time error when new animal is added to our enum
  11. @basdgrt Bear with me Try to see this example in

    a larger context Hope you fi nd all related logic fl ows
  12. @basdgrt Bear with me Try to see this example in

    a larger context Hope you fi nd all related logic fl ows Hope some unit test will fail
  13. @basdgrt Bear with me Try to see this example in

    a larger context Hope you fi nd all related logic fl ows Hope some unit test will fail Hope your coworker notices during code review
  14. @basdgrt This was just the warming up Simple but important

    Part 2 will be more exciting, but also more complex
  15. @basdgrt This was just the warming up Simple but important

    Part 2 will be more exciting, but also more complex Baby steps
  16. @basdgrt Coffee machine class CoffeeMachine { fun makeCoffee(): Coffee {

    val beans = grindBeans() return brew(beans) } }
  17. @basdgrt Coffee machine class CoffeeMachine { fun makeCoffee(): Coffee {

    val beans = grindBeans() return brew(beans) } } fun grindBeans(): CoffeeBeans = TODO() fun brew(beans: CoffeeBeans): Coffee = TODO()
  18. @basdgrt Problem fun grindBeans(): CoffeeBeans class Barista(private val machine: CoffeeMachine)

    { fun handleOrder() { val coffee = machine.makeCoffee() serveToCustomer(coffee) } } Takes no arguments Returns CoffeeBeans
  19. @basdgrt Problem fun grindBeans(): CoffeeBeans class Barista(private val machine: CoffeeMachine)

    { fun handleOrder() { val coffee = machine.makeCoffee() serveToCustomer(coffee) } } Takes no arguments Returns CoffeeBeans Uses grindBeans()
  20. @basdgrt Problem class Barista(private val machine: CoffeeMachine) { fun handleOrder()

    { val coffee = machine.makeCoffee() serveToCustomer(coffee) } } /** * @throws NotEnoughBeansException */ fun grindBeans(): CoffeeBeans Takes no arguments Returns CoffeeBeans Uses grindBeans()
  21. @basdgrt Problem /** * @throws NotEnoughBeansException */ fun grindBeans(): CoffeeBeans

    class Barista(private val machine: CoffeeMachine) { fun handleOrder() = try { val coffee = machine.makeCoffee() serveToCustomer(coffee) } catch (e: NotEnoughBeansException) { // Do something smart... } } Takes no arguments Returns CoffeeBeans Uses grindBeans()
  22. @basdgrt Problem /** * @throws NotEnoughBeansException * @throws NoPowerException */

    fun grindBeans(): CoffeeBeans class Barista(private val machine: CoffeeMachine) { fun handleOrder() = try { val coffee = machine.makeCoffee() serveToCustomer(coffee) } catch (e: NotEnoughBeansException) { // Do something smart... } } Takes no arguments Returns CoffeeBeans Uses grindBeans()
  23. @basdgrt Problem summary Function signature is incorrect Compiler doesn’t force

    us to handle the unhappy path Can’t design for compile time safety
  24. @basdgrt So what’s the big deal? Improper error handling gives

    runtime issues Runtime issues are expensive to fi x
  25. @basdgrt So what’s the big deal? Improper error handling gives

    runtime issues Runtime issues are expensive to fi x Readability suffers
  26. @basdgrt Saunders Mac Lane - Categories for the Working Mathematician

    “A monad in X is just a monoid in the category of endofunctors of X, with product × replaced by composition of endofunctors and unit set by the identity endofunctor.”
  27. @basdgrt Different monad fl avours Nullability: Maybe/Option monad Iterable: List

    monad Logging: Writer monad Error handling: Either monad And many more…
  28. @basdgrt Coding up an Either sealed class Either<out A, out

    B> { data class Failure<out A>(val value: A) : Either<A, Nothing>() data class Success<out B>(val value: B) : Either<Nothing, B>() }
  29. @basdgrt Coding up an Either sealed class Either<out A, out

    B> { data class Failure<out A>(val value: A) : Either<A, Nothing>() data class Success<out B>(val value: B) : Either<Nothing, B>() } Can only be an instance of failure or success
  30. @basdgrt Coding up an Either sealed class Either<out A, out

    B> { data class Failure<out A>(val value: A) : Either<A, Nothing>() data class Success<out B>(val value: B) : Either<Nothing, B>() } Can only be an instance of failure or success Covariant generic types to represent the left and right compartments
  31. @basdgrt Coding up an Either sealed class Either<out A, out

    B> { data class Failure<out A>(val value: A) : Either<A, Nothing>() data class Success<out B>(val value: B) : Either<Nothing, B>() } Can only be an instance of failure or success Covariant generic types to represent the left and right compartments Right compartment is empty
  32. @basdgrt Coding up an Either sealed class Either<out A, out

    B> { data class Failure<out A>(val value: A) : Either<A, Nothing>() data class Success<out B>(val value: B) : Either<Nothing, B>() } Can only be an instance of failure or success Covariant generic types to represent the left and right compartments Right compartment is empty Left compartment is empty
  33. @basdgrt Updated function signature fun grindBeans(): Either<MachineFailure, CoffeeBeans> Function signature

    tells the full story class CoffeeMachine { fun makeCoffee(): Coffee { val beans = grindBeans() return brew(beans) } }
  34. @basdgrt Updated function signature fun grindBeans(): Either<MachineFailure, CoffeeBeans> Function signature

    tells the full story class CoffeeMachine { fun makeCoffee(): Coffee { val beans = grindBeans() return brew(beans) } } Compilation failure
  35. @basdgrt Handling failures class CoffeeMachine { fun makeCoffee(): Either<MachineFailure, Coffee>

    { val beansEither = grindBeans() return when (beansEither) { is Either.Failure -> beansEither // instance of Failure is Either.Success -> Either.Success(brew(beansEither.value)) } } }
  36. @basdgrt Handling failures class CoffeeMachine { fun makeCoffee(): Either<MachineFailure, Coffee>

    { val beansEither = grindBeans() return when (beansEither) { is Either.Failure -> beansEither // instance of Failure is Either.Success -> Either.Success(brew(beansEither.value)) } } } Indicate that makeCoffee() can fail
  37. @basdgrt Handling failures class CoffeeMachine { fun makeCoffee(): Either<MachineFailure, Coffee>

    { val beansEither = grindBeans() return when (beansEither) { is Either.Failure -> beansEither // instance of Failure is Either.Success -> Either.Success(brew(beansEither.value)) } } } Indicate that makeCoffee() can fail Just return the failure
  38. @basdgrt Handling failures class CoffeeMachine { fun makeCoffee(): Either<MachineFailure, Coffee>

    { val beansEither = grindBeans() return when (beansEither) { is Either.Failure -> beansEither // instance of Failure is Either.Success -> Either.Success(brew(beansEither.value)) } } } Indicate that makeCoffee() can fail Just return the failure Wrap the result of brew(), we need to return an Either
  39. @basdgrt 1. Take the value from the right side of

    the box 2. Apply a function Visualise map
  40. @basdgrt 1. Take the value from the right side of

    the box 2. Apply a function Visualise map
  41. @basdgrt 1. Take the value from the right side of

    the box 2. Apply a function 3. Put the result back into the box Visualise map
  42. @basdgrt Implement map inline fun <A, B, C> Either<A, B>.map(fn:

    (B) -> C) = when (this) { is Either.Failure -> this is Either.Success -> Either.Success(fn(this.value)) }
  43. @basdgrt Implement map inline fun <A, B, C> Either<A, B>.map(fn:

    (B) -> C) = when (this) { is Either.Failure -> this is Either.Success -> Either.Success(fn(this.value)) } Takes a function to convert some object of type B to some object of type C
  44. @basdgrt Implement map inline fun <A, B, C> Either<A, B>.map(fn:

    (B) -> C) = when (this) { is Either.Failure -> this is Either.Success -> Either.Success(fn(this.value)) } Takes a function to convert some object of type B to some object of type C Just return the failure
  45. @basdgrt Implement map inline fun <A, B, C> Either<A, B>.map(fn:

    (B) -> C) = when (this) { is Either.Failure -> this is Either.Success -> Either.Success(fn(this.value)) } Takes a function to convert some object of type B to some object of type C Just return the failure Apply function and wrap the result
  46. @basdgrt Brew can also fail… fun brew(beans: CoffeeBeans): Coffee fun

    makeCoffee(): Either<MachineFailure, Coffee> { return grindBeans().map { brew(it) } }
  47. @basdgrt Brew can also fail… fun brew(beans: CoffeeBeans): Either<MachineFailure, Coffee>

    fun makeCoffee(): Either<MachineFailure, Coffee> { return grindBeans().map { brew(it) } }
  48. @basdgrt Brew can also fail… fun brew(beans: CoffeeBeans): Either<MachineFailure, Coffee>

    fun makeCoffee(): Either<MachineFailure, Coffee> { return grindBeans().map { brew(it) } } Fails to compile…
  49. @basdgrt Brew can also fail… fun brew(beans: CoffeeBeans): Either<MachineFailure, Coffee>

    fun makeCoffee(): Either<MachineFailure, Coffee> { return grindBeans().map { brew(it) } } Fails to compile…
  50. @basdgrt Either inception 1. Take the value from the right

    side of the box 2. Apply a function 3. Put the result back into the box
  51. @basdgrt Visualise fl at map 1. Take the value from

    the right side of the box 2. Apply a function which returns a box
  52. @basdgrt Visualise fl at map 1. Take the value from

    the right side of the box 2. Apply a function which returns a box
  53. @basdgrt Visualise fl at map 1. Take the value from

    the right side of the box 2. Apply a function which returns a box 3. Return the new box as a result
  54. @basdgrt Implement fl at map inline fun <A, B, C>

    Either<A, B>.flatMap(fn: (B) -> Either<A, C>) = when (this) { is Either.Failure -> this is Either.Success -> fn(this.value) }
  55. @basdgrt Implement fl at map inline fun <A, B, C>

    Either<A, B>.flatMap(fn: (B) -> Either<A, C>) = when (this) { is Either.Failure -> this is Either.Success -> fn(this.value) } Takes a function to convert some object of type B to an Either<A, C>
  56. @basdgrt Implement fl at map inline fun <A, B, C>

    Either<A, B>.flatMap(fn: (B) -> Either<A, C>) = when (this) { is Either.Failure -> this is Either.Success -> fn(this.value) } Takes a function to convert some object of type B to an Either<A, C> Function returns an either, no need to wrap the result
  57. @basdgrt Nesting fun brew(beans: CoffeeBeans, water: Water): Either<MachineFailure, Coffee> fun

    boilWater(): Either<MachineFailure, Water> fun makeCoffee(): Either<MachineFailure, Coffee> { return grindBeans().flatMap { beans -> boilWater().flatMap { water -> brew(beans, water) } } }
  58. @basdgrt Arrow-kt documentation “The purpose of monad comprehensions is to

    compose sequential chains of actions in a style that feels natural for programmers of all backgrounds.”
  59. @basdgrt Simple but effective inline fun <A, B> Either<A, B>.onFailure(fn:

    (Either.Failure<A>) -> Nothing): B = when(this) { is Either.Failure -> fn(this) is Either.Success -> value }
  60. @basdgrt Simple but effective inline fun <A, B> Either<A, B>.onFailure(fn:

    (Either.Failure<A>) -> Nothing): B = when(this) { is Either.Failure -> fn(this) is Either.Success -> value } Apply function if we are dealing with a failure
  61. @basdgrt Simple but effective inline fun <A, B> Either<A, B>.onFailure(fn:

    (Either.Failure<A>) -> Nothing): B = when(this) { is Either.Failure -> fn(this) is Either.Success -> value } Apply function if we are dealing with a failure Return unboxed value on success
  62. @basdgrt Simple but effective inline fun <A, B> Either<A, B>.onFailure(fn:

    (Either.Failure<A>) -> Nothing): B = when(this) { is Either.Failure -> fn(this) is Either.Success -> value } Apply function if we are dealing with a failure Return unboxed value on success fun makeCoffee(): Either<MachineFailure, Coffee> { val beans = grindBeans().onFailure { return it } val water = boilWater().onFailure { return it } return brew(beans, water) }
  63. @basdgrt Simple but effective inline fun <A, B> Either<A, B>.onFailure(fn:

    (Either.Failure<A>) -> Nothing): B = when(this) { is Either.Failure -> fn(this) is Either.Success -> value } Apply function if we are dealing with a failure Return unboxed value on success fun makeCoffee(): Either<MachineFailure, Coffee> { val beans = grindBeans().onFailure { return it } val water = boilWater().onFailure { return it } return brew(beans, water) } Return from within a lambda out of the enclosing function
  64. @basdgrt Arrow-kt fun makeCoffee() = IO.fx { val beans =

    grindBeans().bind() val water = boilWater().bind() brew(beans, water) }
  65. @basdgrt Modeling failures sealed class MachineFailure { object NotEnoughBeans :

    MachineFailure() object MissingFilter : MachineFailure() }
  66. @basdgrt Fold inline fun <A, B, C> Either<A, B>.fold(onFailure: (A)

    -> C, onSuccess: (B) -> C) = when (this) { is Either.Failure -> onFailure(value) is Either.Success -> onSuccess(value) }
  67. @basdgrt Let’s help out our barista class Barista(private val machine:

    CoffeeMachine) { fun handleOrder() { machine.makeCoffee().fold( onSuccess = { serveToCustomer(it) }, onFailure = { when (it) { MachineFailure.MissingFilter -> TODO() MachineFailure.NotEnoughBeans -> TODO() } }) } }
  68. @basdgrt Let’s help out our barista Serve coffee on success

    class Barista(private val machine: CoffeeMachine) { fun handleOrder() { machine.makeCoffee().fold( onSuccess = { serveToCustomer(it) }, onFailure = { when (it) { MachineFailure.MissingFilter -> TODO() MachineFailure.NotEnoughBeans -> TODO() } }) } }
  69. @basdgrt Let’s help out our barista Handle errors on failure

    Serve coffee on success class Barista(private val machine: CoffeeMachine) { fun handleOrder() { machine.makeCoffee().fold( onSuccess = { serveToCustomer(it) }, onFailure = { when (it) { MachineFailure.MissingFilter -> TODO() MachineFailure.NotEnoughBeans -> TODO() } }) } }
  70. @basdgrt Let’s help out our barista Handle errors on failure

    Serve coffee on success class Barista(private val machine: CoffeeMachine) { fun handleOrder() { machine.makeCoffee().fold( onSuccess = { serveToCustomer(it) }, onFailure = { when (it) { MachineFailure.MissingFilter -> TODO() MachineFailure.NotEnoughBeans -> TODO() } }) } } When is only exhaustive if it’s used as an expression
  71. @basdgrt Making when exhaustive fun Any.exhaustive() = this.let { }

    class Barista(private val machine: CoffeeMachine) { fun handleOrder() { machine.makeCoffee().fold( onSuccess = { serveToCustomer(it) }, onFailure = { when (it) { MachineFailure.MissingFilter -> TODO() MachineFailure.NotEnoughBeans -> TODO() }.exhaustive() }) } }
  72. @basdgrt We’ve come a long way Function signatures are correct

    Compiler forces us to handle the unhappy path
  73. @basdgrt We’ve come a long way Function signatures are correct

    Compiler forces us to handle the unhappy path We can design for compile time safety
  74. @basdgrt The elephant in the room Where do you convert

    an exception to an Either? As close as possible to the function that produces the exception
  75. @basdgrt The elephant in the room Where do you convert

    an exception to an Either? As close as possible to the function that produces the exception At the boundaries of your application
  76. @basdgrt The elephant in the room Where do you convert

    an exception to an Either? As close as possible to the function that produces the exception At the boundaries of your application Accessing resources over HTTP
  77. @basdgrt The elephant in the room Where do you convert

    an exception to an Either? As close as possible to the function that produces the exception At the boundaries of your application Accessing resources over HTTP Saving to a database
  78. @basdgrt Did we just re-invent checked exceptions? Yes and no…

    Checked exceptions don’t work with higher order functions
  79. @basdgrt Did we just re-invent checked exceptions? Yes and no…

    Checked exceptions don’t work with higher order functions Either solution is cleaner
  80. @basdgrt Doesn’t the stdlib provide a Result type? Yes it

    does It’s somewhat similar The failure side is always Throwable
  81. @basdgrt Doesn’t the stdlib provide a Result type? Yes it

    does It’s somewhat similar The failure side is always Throwable Not possible to do exhaustive failure checking
  82. @basdgrt What are the downsides of doing this? Learning curve

    Imposes a coding style Might need an extra dependency (arrow-kt)
  83. @basdgrt What are the downsides of doing this? Learning curve

    Imposes a coding style Might need an extra dependency (arrow-kt) Hard to apply to existing systems