Exploring Hike: Simplifying Optional Values and Result Handling in Elixir

Exploring Hike: Simplifying Optional Values and Result Handling in Elixir

Introduction

Elixir is a powerful functional programming language known for its robust concurrency model and fault-tolerant design. When working with Elixir, handling optional values and managing results with multiple outcomes can sometimes be challenging. That's where the Hike library comes in handy. In this blog post, we'll dive into the world of Hike and explore how it simplifies optional values and result handling in Elixir.

What is Hike?

Hike is a lightweight Elixir library that provides convenient modules for handling optional values and managing results with two possible outcomes. It offers Hike.Option for working with optional values and Hike.Either & Hike.MayFail for handling results with a choice between two alternatives.

The Hike module provides an implementation of the elevated data types. It defines

  • a struct Hike.Option with a single field value which can either be nil or any other value of type t.

  • a struct Hike.Either that represents an "either/or" value. It can contain a value either in left state or in rightstate, but not both

  • a struct Hike.MayFailthat represents an "either/or" value. It can contain a value either in Failure state or Success state, but not both. this is more specific to error and success case.

This implementation of Hike provides shorthand functions to work with elevated data, including mapping, binding, filtering, applying and many more functions

Installation

To use Hike in your Elixir project, you can add it as a dependency in your mix.exs file:

def deps do
  [
    {:hike, "~> 0.1.0"}
  ]
end

After adding the dependency, run mix deps.get to fetch and compile the library.

Why Hike?

The Hike library introduces elevated data types (Option, Either, and MayFail) and provides accompanying functions to work with these types in Elixir. The primary motivation behind using Hike is to handle scenarios where values can be optional, represent success or failure, or have multiple possible outcomes.

Here are a few reasons why you may choose to use Hike in your Elixir projects:

  1. Expressiveness:

    Hike enhances the expressiveness of your code by providing dedicated types (Option, Either, and MayFail) that conveys the intent and semantics of your data. Instead of relying on traditional approaches like using nil, tuples, or exceptions, Hike's elevated data types provide a more intuitive and descriptive way to represent optional, success/failure, or multi-outcome values.

  2. Safer and more predictable code:

    By using Hike's elevated data types, you can explicitly handle scenarios where values may be absent (Option), represent success or failure (MayFail), or have multiple possible outcomes (Either). This approach encourages you to handle all possible cases, reducing the chances of unexpected errors or unintended behaviour. Hike provides functions to work with these types safely and predictably, promoting robust error handling and code clarity.

  3. Functional programming paradigm:

    Hike aligns well with the functional programming paradigm by providing functional constructs like map, bind, and apply. These functions allow you to transform, chain, and apply computations to values of elevated types, promoting immutability and composability. The functional approach helps in writing concise, modular, and reusable code.

  4. Pattern matching and error handling:

    Hike incorporates pattern matching to handle the different outcomes of elevated data types. With pattern matching, you can easily extract and work with the underlying values or apply different logic based on the specific outcome. Hike's functions, such as match enables precise error handling and result evaluation, enhancing the control flow and readability of your code.

  5. Enhanced documentation and understanding:

    By using Hike, you make the intent and behaviour of your code more explicit. Elevated data types convey the possibilities and constraints of your data upfront, making it easier for other developers to understand and reason about your code. Additionally, Hike's functions have clear specifications and type signatures, enabling better documentation and static type-checking tools.

Overall, Hike provides a set of elevated data types and functions that facilitate more expressive, safer, and predictable code, especially in scenarios where values can be optional, represent success/failure, or have multiple possible outcomes. By leveraging Hike, you can enhance the clarity, maintainability, and robustness of your Elixir codebase.

Usage

Hike provides three elevated data types:

  • Hike.Option,

  • Hike.Either, and

  • Hike.MayFail.

    Let's explore each type and its usage.

Hike.Option

Hike.Option represents an optional value, where the value may exist or may not exist. It is useful in scenarios where a function can return nil or an actual value, and you want to handle that gracefully without resorting to conditional statements.

Creating an Option

To create an Option instance or an optional value, you can use the Hike.Option module Hike.Option.some/1 function to wrap a value:

Hike.Option module also provide utility functions to work upon elevated value.

  • some and none are construct function which creates an Option instance

  • apply

  • apply_async

  • map

  • map_async

  • bind

  • bind_async

  • is_some?

  • match

    and many more.

# Option in `some` state.
iex> Hike.Option.some(20)
%Hike.Option{value: 20}

# Option in `none` state.
iex> Hike.Option.none()
%Hike.Option{value: nil}

# we also have shorthand functions available in `Hike` module itself.
iex> Hike.option(20)
%Hike.Option{value: 20}

iex>Hike.option()
%Hike.Option{value: nil}

iex> Hike.option({:ok, 20})
%Hike.Option{value: 20}
# This one is intentional to ignore error msg. if you care about error msg 
# then we have `Hike.Either` and `Hike.MayFail`. where we do care about error as well.
# for more check respective explanation.
iex>Hike.option({:error, "error msg."})
%Hike.Option{value: nil}
# Define a User struct
defmodule User do
  @derive {Jason.Encoder, only: [:id,:age, :name]}
  defstruct [:id, :age, :name]
end

defmodule TestHike do
  # Import the Hike.Option module
  import Hike.Option

  # Simulating a database fetch function
  @spec fetch_user(number) :: Hike.Option.t()
  # Simulating a database fetch function
  def fetch_user(id) do
    # Simulating a database query to fetch a user by ID
    # Returns an Option<User> with some(user) if the user is found
    # Returns an Option<User> with none() if the user is not found
    case id do
      1 -> some(%User{id: 1, age: 30, name: "Vineet Sharma"})
      2 -> some(%User{id: 2, age: 20, name: "Jane Smith"})
      _ -> none()
    end
  end

  # Function to update the user's name to uppercase
  # This function takes a user, a real data type, and returns an elevated data type Option
  def update_name_to_uppercase(user) do
    case user.name do
        nil -> none()
        name -> some(%User{user | name: String.upcase(name)})
    end
  end

# for this case map function will be used like its been used for `increase_age_by1`  


  # Function to increase the user's age by one
  # This function takes a user, a real data type, and returns a real data type user
  def increase_age_by1(user) do
    %User{user | age: user.age + 1}
  end

  # Function to print a user struct as a JSON-represented string
  def print_user_as_json(user) do
    Jason.encode!(user) |> IO.puts
  end

  # Example: Fetching a user from the database, updating the name, and matching the result
  def test_user() do
    user_id = 1

# 1. Expressiveness: Using Hike's Option type to handle optional values
# Here my intention is to fetch User and modify User Name, and increase User age. and then finally give me the result.
# if we have one then all intended function will work accordingly 
# else we will get response in `None` state  which will be mapped  out by match function. and we will get expected Result.
    fetch_user(user_id)
    |> bind(&update_name_to_uppercase/1)
    |> map(&increase_age_by1/1)
    |> match(&print_user_as_json/1, fn -> IO.puts("User not found") end)

    user_id = 3

    # 2. Safer and more predictable code: Handling all possible cases explicitly
    fetch_user(user_id)
    |> bind(&update_name_to_uppercase/1)
    |> map(&increase_age_by1/1)
    |> match(&print_user_as_json/1, fn -> IO.puts("User not found") end)
  end
end

Output

iex> TestHike.test_user
# User ID: 1
{"id":1,"age":31,"name":"JOHN DOE"}
# User ID: 3
User not found
  1. Expressiveness:

    The fetch_user/1 function returns an Option<User>, indicating the possibility of a user not being found in the database. The update_name_to_uppercase/1 function uses the Option type to ensure the updated name is wrapped in an Option as well.

  2. Safer and more predictable code:

    Both cases where the user is found and where the user is not found are explicitly handled using bind/2 and map/2 operations. This ensures that all possible cases are accounted for, promoting a safer and more predictable code.

  3. Functional programming paradigm:

    The functions bind/2 and map/2 are functional constructs provided by Hike. They allow for the composability and transformation of values. The bind/2 function is used to transform and chain operations, while the map/2 function is used to transform the value within the Option type.

  4. Pattern matching and error handling:

    The match/3 function is used to pattern match the result of the operations. It allows for different logic to be applied based on whether a user is found or not. In the example, the match/3 function is used to either print the user as a JSON string or display a "User not found" message.

  5. Enhanced documentation and understanding:

    The code structure and function names are chosen to convey the intent and behaviour. The use of elevated data types, such as Option, and explicit handling of possible cases make the code more self-documenting. Other developers can easily understand the possibilities and constraints of the data and follow the control flow.

By incorporating these five points, the Code with Hike demonstrates how it improves expressiveness, code safety, adherence to functional programming principles, error handling, and code comprehension. It showcases the benefits of using Hike's elevated data types and functions for handling optional values and multiple outcomes concisely and intuitively.

Hike.Either

Hike.Either represents a value that can be one of two possibilities: either a left state or a right state. It is commonly used in error handling or when a function can return different types of results.

Creating an Either

To create an Either instance, you can use the Hike.Either.right/1 and Hike.Either.left/1 function to wrap a value in right and left state respectively :

iex> Hike.Either.right(5)
%Hike.Either{l_value: nil, r_value: 5, is_left?: false}
# same example can be rewritten with `Either`
# Define a User struct
defmodule User do
  @derive Jason.Encoder
  defstruct [:id, :age, :name]
end
defmodule TestHike do
  # Import the Hike.Either module
  import Hike.Either

  # Simulating a database fetch function
  @spec fetch_user(number) :: Hike.Either.either(%User{}) | Hike.Either.either(String.t())
  def fetch_user(id) do
    # Simulating a database query to fetch a user by ID
    # Returns an Either<string, User> with left(string) if the user is not found
    # Returns an Either<string, User> with right(User) if the user is found
    case id do
      1 -> right(%User{id: 1, age: 30, name: "Vineet Sharma"})
      2 -> right(%User{id: 2, age: 20, name: "Jane Smith"})
      _ -> left("User not found")
    end
  end

# Function to update the user's name to uppercase if possible
# This function takes a User struct and returns an Either<string, User>
def update_name_to_uppercase(user) do
  case user.name do
    nil -> left("User name is missing")
    name ->  right(%User{user | name: String.upcase(name)})
  end
end

  # Function to increase the user's age by one
  # This function takes a User struct and returns a real data type User with updated values.
  def increase_age_by_1(user) do
    %User{user | age: user.age + 1}
  end

  # Function to print a user struct as a JSON-represented string
  def print_user_as_json(user) do
    user
    |> Jason.encode!()
    |> IO.puts()
  end

  # Example: Fetching a user from the database, updating the name, and matching the result
  def test_user() do
    user_id = 1

    # Fetch the user from the database
    fetch_user(user_id)
    # Update the name to uppercase using bind
    |> bind_right(&update_name_to_uppercase/1)
    # Increase the age by one using map
    |> map_right(&increase_age_by_1/1)
    # Print the user as a JSON string using map
    |> map_right(&print_user_as_json/1)
    # finally match the respective result with a appropriate function.
    |> match(fn err ->err end, fn x -> x end)

    user_id = 3

    # Fetch the user from the database
    fetch_user(user_id)
    # Update the name to uppercase using bind
    |> bind_right(&update_name_to_uppercase/1)
    # Increase the age by one using map
    |> map_right(&increase_age_by_1/1)
    # Print the user as a JSON string using map
    |> map_right(&print_user_as_json/1)
    # finally match the respective result with a appropriate function.
    |> match(fn err ->err end, fn x -> x end)
  end
end
#output 
iex> TestHike.test_user
#user_id = 1
    {"age":31,"id":1,"name":"VINEET SHARMA"}
#user_id = 3
    "User not found"

Hike.MayFail

Hike.MayFail represents a value that may either succeed with a value or fail with an error. It specifies the functionality ofHike.Either with failure and success making it suitable for scenarios where a value can be successful and can also potentially fail.

Creating a MayFail

To create a MayFail instance, you can use the construct function Hike.MayFail.success/1 to wrap a success value and Hike.MayFail.failure to wrap a failure / an ERROR

iex> may_fail = Hike.MayFail.success(42)
%Hike.MayFail{failure: nil, success: 42, is_success?: true}
# same example can be rewritten with `MayFail`
# Define a User struct
defmodule User do
  @derive Jason.Encoder
  defstruct [:id, :age, :name]
end

defmodule TestHike do
  # Import the Hike.MayFail module
  import Hike.MayFail

  # Simulating a database fetch function
  @spec fetch_user(number) :: Hike.MayFail.mayfail(String.t()) | Hike.MayFail.mayfail_success(%User{})
  def fetch_user(id) do
    # Simulating a database query to fetch a user by ID
    # Returns an MayFail<string, User> with success(user) if the user is found
    # Returns an MayFail<string, User> with failure("User not found") if the user is not found
    case id do
      1 -> success(%User{id: 1, age: 30, name: "Vineet Sharma"})
      2 -> success(%User{id: 2, age: 20, name: nil})
      _ -> failure("User not found")
    end
  end

# Function to update the user's name to uppercase if possible
# This function takes a User struct and returns an MayFail<string, User>
def update_name_to_uppercase(user) do
  case user.name do
    nil -> failure("User name is missing")
    name -> success(%User{user | name: String.upcase(name)})
  end
end

  # Function to increase the user's age by one
  # This function takes a User struct and returns a real data type User with updated values.
  def increase_age_by_1(user) do
    %User{user | age: user.age + 1}
  end

  # Function to print a user struct as a JSON-represented string
  def print_user_as_json(user) do
    user
    |> Jason.encode!()
    |> IO.puts()
  end

  def test_user() do
    fetch_user(1)
    |> bind_success(&update_name_to_uppercase/1)
    |> map_success(&increase_age_by_1/1) 
    |> IO.inspect()
    |> match(&IO.puts/1, &print_user_as_json/1)

    fetch_user(2)
    |> bind_success(&update_name_to_uppercase/1)
    |> map_success(&increase_age_by_1/1) 
    |> IO.inspect()
    |> match(&IO.puts/1, &print_user_as_json/1)

    fetch_user(3)
    |> bind_success(&update_name_to_uppercase/1)
    |> map_success(&increase_age_by_1/1) 
    |> IO.inspect()
    |> match(&IO.puts/1, &print_user_as_json/1)

  end
end
#output 
iex> TestHike.test_user
# user id =1
%Hike.MayFail{
  failure: nil,
  success: %User{id: 1, age: 31, name: "VINEET SHARMA"},
  is_success?: true
}
{"age":31,"id":1,"name":"VINEET SHARMA"}

# user id = 2

%Hike.MayFail{failure: "User name is missing", success: nil, is_success?: false}
User name is missing

#user id = 3

%Hike.MayFail{failure: "User not found", success: nil, is_success?: false}
User not found
:ok

Conclusion

Hike is a valuable library for simplifying optional values and result handling in Elixir. With Hike.Option, you can elegantly handle scenarios where a value may or may not be present. Hike.Either provides a clean and intuitive way to manage results with multiple possible outcomes. When combined with Task for asynchronous operations, Hike becomes even more powerful. all available async versions of map, bind, apply and match function makes it worth of use it.

By leveraging the features provided by Hike, you can write cleaner and more expressive code, making your Elixir applications more robust and maintainable.

Give Hike a try in your next Elixir project and experience the benefits it offers in handling optional values and result handling.

if you feel any issue, kindly feel free to raise an issue on GitHub.

Happy coding!