Adding Monad To Golang
I've coded in Rust and enjoy the algebraic data type that Rust has. There's no null, I don't need to try catch, and there's a syntax for early return. But as much as I love rust, it's a hard language to learn. You need to understand borrow checker use arc mutex to share database connection between async. Right now, there are not a lot of jobs out there that use rust. Especially for web development.
Why don't use golang?
Right now, golang jobs out there have increased a lot in the DevOps space and also in the web development space. Golang is simple, I bet it'll take you less than 1 week to get productive in it. Golang also uses garbage collector and I don't need to think about the borrow checker rule. But... golang doesn't have a strong type system. It has null as a value, doesn't have an algebraic data type, and doesn't have an option and result. This might look like I'm being petty but trust me. Debugging null pointer in golang is not fun if you have a recursive data structure (taken from real-life experience).
Can't we make it better?
Before golang 1.18, this problem has a hard solution. Since golang doesn't have generic, we couldn't make a generic container that could accept any type. We could use interface{}
or reflection, but we'll lose the strict type and incur a runtime performance penalty.
But fear no more. In golang 1.18, they introduce generic. Horray 🎉. Now we could make a simple container that could accept generic type.
Let's add option and result to golang
Now, we could make a simple struct for the option like this
type Option[T any] struct {
value T
isSome bool
}
And we could make one for result like this
type Result[T any] struct {
value T
err error
isErr bool
}
After that, we just need to implement map, getter, unwrap, and constructor right? Well... yeah but that's tedious and thankfully there's already a good golang package called samber/mo
that already implements most of the stuff that we need.
Now let's try to make a simple http server.
Http server example without monad
package main
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jellydator/validation"
"github.com/jellydator/validation/is"
_ "github.com/mattn/go-sqlite3"
)
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
func (u User) Validate() error {
return validation.ValidateStruct(&u,
validation.Field(&u.Name, validation.Required, validation.Length(1, 255)),
validation.Field(&u.Email, validation.Required, is.Email),
validation.Field(&u.Password, validation.Required, validation.Length(8, 255)),
)
}
var db *sql.DB
func init() {
var err error
db, err = sql.Open("sqlite3", "./user.db")
if err != nil {
panic(err)
}
const create string = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT,
email TEXT,
password TEXT
);`
if _, err := db.Exec(create); err != nil {
panic(err)
}
}
func main() {
fmt.Println("starting server in port 3000 ")
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Post("/", handler)
http.ListenAndServe(":3000", r)
}
func handler(w http.ResponseWriter, r *http.Request) {
var user User
err := json.NewDecoder(r.Body).Decode(&user)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := user.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_, err = db.Exec("INSERT INTO users (name, email, password) VALUES (?, ?, ?)", user.Name, user.Email, user.Password)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Write([]byte("success"))
}
This is a basic golang http server that does db migration and http post requests. As you can see, golang needs to check on the error, and if it's an error then we need to early return it. If we don't check the error, the value of user
will be nil
.
Now, let's try with result and option
http server example with monad
package main
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jellydator/validation"
"github.com/jellydator/validation/is"
_ "github.com/mattn/go-sqlite3"
. "github.com/samber/mo"
)
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
func (u User) Validate() error {
return validation.ValidateStruct(&u,
validation.Field(&u.Name, validation.Required, validation.Length(1, 255)),
validation.Field(&u.Email, validation.Required, is.Email),
validation.Field(&u.Password, validation.Required, validation.Length(8, 255)),
)
}
var db *sql.DB
func init() {
const create string = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT,
email TEXT,
password TEXT
);`
db = TupleToResult(sql.Open("sqlite3", "./user.db")).
Map(func(db *sql.DB) (*sql.DB, error) {
_, err := db.Exec(create)
return db, err
}).
MapErr(func(err error) (*sql.DB, error) {
panic(err)
}).MustGet()
}
func main() {
fmt.Println("starting server in port 3000 ")
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Post("/", handler)
http.ListenAndServe(":3000", r)
}
func handler(w http.ResponseWriter, r *http.Request) {
user := Try(func() (Option[User], error) {
var user User
err := json.NewDecoder(r.Body).Decode(&user)
return Some(user), err
}).Map(func(user Option[User]) (Option[User], error) {
return user, user.MustGet().Validate()
})
if user.IsError() {
http.Error(w, user.Error().Error(), http.StatusBadRequest)
return
}
user.Map(func(user Option[User]) (Option[User], error) {
u := user.MustGet()
_, err := db.Exec("INSERT INTO users (name, email, password) VALUES (?, ?, ?)", u.Name, u.Email, u.Password)
return user, err
}).Match(
func(user Option[User]) (Option[User], error) {
w.Write([]byte("success"))
return user, nil
},
func(err error) (Option[User], error) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return None[User](), err
},
)
}
At first glance, this looks much more cluttered. The reason why is because golang right now doesn't have a lightweight anonymous function declaration. There's an ongoing proposal for it. But it looks like it won't be approved anytime soon.
Ignoring the extra mess that the unneeded type in the anonymous function gives, this flow is much nicer than the non-monad version. The user
variable is parsed from json and it could error out. After that, we want to validate it. Since we use option and Map
function, the validation only runs if user option is not empty. This way we don't need to error check if the decoding failed or not. Now, the reason why I check if user.IsError()
is because I want to return with http.StatusBadRequest. In the next post on this blog, we'll explore the idea of using Enum, or in samber/mo
package called Either. But for this demonstration, we will just error check it.
At the end of the function, we just match if the user has an error out or not. If it errors out then we write the http response with internal server error
and if it's successful then we write with success. This way we can code the happy path first and we deal with the error out later.
It's ugly but hey it have a nicer flow
Of course, this is a much worst developer experience compared to Rust where they have native support for this. It also looks ugly compared to just error checking. But I would argue that this is a much nicer flow compared to checking nil
and err
every time.