DEV Community

Cover image for Functional Programming in Go with IBM fp-go: Error Handling Made Explicit
Piyush Chauhan
Piyush Chauhan

Posted on

Functional Programming in Go with IBM fp-go: Error Handling Made Explicit

Functional programming (FP) principles are gaining popularity in modern software development due to their emphasis on immutability, composability, and explicitness. While Go is traditionally an imperative language, the fp-go library, developed by IBM, introduces FP abstractions such as Option, Either, Fold, and utilities for functional composition. In this article, we will explore how to use fp-go to handle errors explicitly, define function signatures with multiple error types, and build a real-world CRUD API example demonstrating these concepts.

Why Functional Error Handling?

Error handling is crucial for building reliable software. Traditional Go error handling relies on returning error values, which can be unintentionally ignored or mishandled. Functional error handling introduces abstractions like:

  1. Option: Represents optional values, akin to Some and None in other FP languages.
  2. Either: Encapsulates a value that can either be a Right (success) or Left (failure), making error propagation explicit.
  3. Tagged Unions: Allow function signatures to clearly define possible error types.
  4. Composition: Enables chaining operations while handling errors naturally.

Let’s dive into these concepts and see how fp-go facilitates them in Go.


Getting Started with fp-go

First, add fp-go to your Go project:

go get github.com/IBM/fp-go
Enter fullscreen mode Exit fullscreen mode

Import the necessary modules:

import (
    either "github.com/IBM/fp-go/either"
    option "github.com/IBM/fp-go/option"
)
Enter fullscreen mode Exit fullscreen mode

Option: Handling Optional Values

Option represents a value that may or may not exist. It is either Some(value) or None.

Example: Parsing an Integer

func parseInt(input string) option.Option[int] {
    value, err := strconv.Atoi(input)
    if err != nil {
        return option.None[int]()
    }
    return option.Some(value)
}

func main() {
    opt := parseInt("42")

    option.Fold(
        func() { fmt.Println("No value") },
        func(value int) { fmt.Printf("Parsed value: %d\n", value) },
    )(opt)
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways:

  • Option eliminates nil values.
  • Fold is used to handle both cases (Some or None).

Either: Handling Errors Explicitly

Either represents a computation that can result in two possibilities:

  1. Left: Represents an error.
  2. Right: Represents a successful result.

Example: Safe Division

type MathError struct {
    Code    string
    Message string
}

func safeDivide(a, b int) either.Either[MathError, int] {
    if b == 0 {
        return either.Left(MathError{Code: "DIV_BY_ZERO", Message: "Cannot divide by zero"})
    }
    return either.Right(a / b)
}

func main() {
    result := safeDivide(10, 0)

    either.Fold(
        func(err MathError) { fmt.Printf("Error [%s]: %s\n", err.Code, err.Message) },
        func(value int) { fmt.Printf("Result: %d\n", value) },
    )(result)
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways:

  • Either separates success and failure paths.
  • Fold simplifies handling both cases in one place.

Function Signatures with Multiple Error Types

Real-world applications often need to handle multiple types of errors. By using tagged unions, we can define explicit error types.

Example: Tagged Union for Errors

type AppError struct {
    Tag     string
    Message string
}

const (
    MathErrorTag    = "MathError"
    DatabaseErrorTag = "DatabaseError"
)

func NewMathError(msg string) AppError {
    return AppError{Tag: MathErrorTag, Message: msg}
}

func NewDatabaseError(msg string) AppError {
    return AppError{Tag: DatabaseErrorTag, Message: msg}
}

func process(a, b int) either.Either[AppError, int] {
    if b == 0 {
        return either.Left(NewMathError("Division by zero"))
    }
    return either.Right(a / b)
}

func main() {
    result := process(10, 0)

    either.Fold(
        func(err AppError) { fmt.Printf("Error [%s]: %s\n", err.Tag, err.Message) },
        func(value int) { fmt.Printf("Processed result: %d\n", value) },
    )(result)
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Tagged unions make errors self-documenting.
  • Explicit types reduce ambiguity in error handling.

Real-World Example: CRUD API

Let’s implement a simple CRUD API with explicit error handling using Either.

Model and Error Definitions

type User struct {
    ID    int
    Name  string
    Email string
}

type AppError struct {
    Code    string
    Message string
}

const (
    NotFoundError    = "NOT_FOUND"
    ValidationError  = "VALIDATION_ERROR"
    DatabaseError    = "DATABASE_ERROR"
)

func NewAppError(code, message string) AppError {
    return AppError{Code: code, Message: message}
}
Enter fullscreen mode Exit fullscreen mode

Repository Layer

var users = map[int]User{
    1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
}

func getUserByID(id int) either.Either[AppError, User] {
    user, exists := users[id]
    if !exists {
        return either.Left(NewAppError(NotFoundError, "User not found"))
    }
    return either.Right(user)
}
Enter fullscreen mode Exit fullscreen mode

Service Layer

func validateUser(user User) either.Either[AppError, User] {
    if user.Name == "" || user.Email == "" {
        return either.Left(NewAppError(ValidationError, "Name and email are required"))
    }
    return either.Right(user)
}

func createUser(user User) either.Either[AppError, User] {
    validation := validateUser(user)
    return either.Chain(
        func(validUser User) either.Either[AppError, User] {
            user.ID = len(users) + 1
            users[user.ID] = user
            return either.Right(user)
        },
    )(validation)
}
Enter fullscreen mode Exit fullscreen mode

Controller

func handleGetUser(id int) {
    result := getUserByID(id)

    either.Fold(
        func(err AppError) { fmt.Printf("Error [%s]: %s\n", err.Code, err.Message) },
        func(user User) { fmt.Printf("User: %+v\n", user) },
    )(result)
}

func handleCreateUser(user User) {
    result := createUser(user)

    either.Fold(
        func(err AppError) { fmt.Printf("Error [%s]: %s\n", err.Code, err.Message) },
        func(newUser User) { fmt.Printf("Created user: %+v\n", newUser) },
    )(result)
}

func main() {
    handleGetUser(1)
    handleCreateUser(User{Name: "Bob", Email: "bob@example.com"})
    handleGetUser(2)
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Using fp-go in Go, we can:

  • Model errors explicitly using Either.
  • Represent optional values with Option.
  • Handle multiple error types via tagged unions.
  • Build maintainable and composable APIs.

These patterns make your Go code more robust, readable, and functional. Whether you’re building a CRUD API or complex business logic, fp-go empowers you to handle errors cleanly and consistently.

Top comments (0)