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 fieldvalue
which can either benil
or any other value of typet
.a struct
Hike.Either
that represents an "either/or" value. It can contain a value either inleft
state or inright
state, but not botha struct
Hike.MayFail
that represents an "either/or" value. It can contain a value either inFailure
state orSuccess
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:
Expressiveness:
Hike enhances the expressiveness of your code by providing dedicated types (
Option
,Either
, andMayFail
) that conveys the intent and semantics of your data. Instead of relying on traditional approaches like usingnil
, tuples, or exceptions, Hike's elevated data types provide a more intuitive and descriptive way to represent optional, success/failure, or multi-outcome values.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.Functional programming paradigm:
Hike
aligns well with the functional programming paradigm by providing functional constructs likemap
,bind
, andapply
. 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.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 asmatch
enables precise error handling and result evaluation, enhancing the control flow and readability of your code.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
andnone
are construct function which creates anOption
instanceapply
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
Expressiveness:
The
fetch_user/1
function returns anOption<User>
, indicating the possibility of a user not being found in the database. Theupdate_name_to_uppercase/1
function uses the Option type to ensure the updated name is wrapped in an Option as well.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.
Functional programming paradigm:
The functions
bind/2
andmap/2
are functional constructs provided by Hike. They allow for the composability and transformation of values. Thebind/2
function is used to transform and chain operations, while themap/2
function is used to transform the value within the Option type.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, thematch/3
function is used to either print the user as a JSON string or display a "User not found" message.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!
Subscribe to my newsletter
Read articles from Vineet Sharma directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Vineet Sharma
Vineet Sharma
I am a software developer with decade of experience in development.