Building my college project with Gleam

Picking a language

Every semester my college assigns us a major project. This usually meant coming up with a startup concept or designing a website in Figma, but we never got to actually put our ideas into practice. This semester was different.

Our task was to build a project aimed at helping firefighters handle a large volume of incidents. Being responsible for the backend mostly by myself, I was a bit insecure. I needed something reliable and built for getting things done fast.

The rest of the class went with safer, popular choices like Python, TypeScript and Java. They're great for building an API, but I couldn't imagine doing all that work while carefully checking for every exception or null value.

I wanted to try something different.

Gleam to the rescue

pub fn main() -> Nil {
io.println("Hello BEAM!")
}

Besides not having any experience with functional programming or the BEAM ecosystem, I wanted to give this new language a try. It's syntax is simple, the community is friendly and the language is incredibly small.

Errors as values

One of the main reasons why I went with Gleam was their approach to error handling. Errors in Gleam are treated as values you can return from any function. And the best part, you can see if a function can fail by looking at its return type.

fn do_work() -> Result(String, MyCustomError) {
todo as "body of the function"
}

As a neurodivergent person, this level of explicitness really means the world to me. I never had to guess or look deeply into documentation to know what could go wrong. Not only that, but you can define your own custom error types.

type MyCustomError {
ServerLostConnection
UserNotFound
UserNotAuthorized
}

Pattern matching

Being able to build custom error types pairs really well with Gleam's support for pattern matching. You can define one at the top level scope and easily switch on all possible values whenever needed.

case err {
ServerLostConnection -> todo as "HTTP 500"
UserNotFound -> todo as "HTTP 404"
UserNotAuthorized -> todo as "HTTP 403"
}

The compiler warns you when missing one or more branches and it will refuse to compile the project during that state.

Handling HTTP Requests

For the http server, I used mist paired with wisp for the web framework. Setting up the request handler was pretty straightforward, all the documentation you need is available in their docs , together with a few examples.

pub fn handle_request(
request: wisp.Request,
ctx: Context,
) -> wisp.Response {
use request <- web.middleware(request, ctx)

// We can also pattern match on the path segments!
case wisp.path_segments(request) {
["user", "login"] -> login.handle_request(request, ctx)
["user", "profile"] -> get_user_profile.handle_request(request, ctx)
["dashboard", "stats"] -> dashboard.handle_request(request, ctx)

// Other endpoints..
[] -> wisp.ok()
_ -> wisp.not_found()
}
}

Supervisors and OTP

Since I'm using the erlang compilation target, It allows me to set up a process supervisor to watch for potential crashes. No need to worry about the application going down because your intern vibe coded one of the endpoints.

If one of the processes crashes for a unknown reason, the supervisor will notice it and immediately start a new one to replace it, keeping the application alive and your mind at ease.

pub fn init(
db_config: pog.Config,
http_handler: fn(Request) -> Response,
secret_key: String,
) -> Result(actor.Started(supervisor.Supervisor), actor.StartError) {
let handler = wisp_mist.handler(http_handler, secret_key)

let mist_builder =
handler
|> mist.new()
|> mist.bind("0.0.0.0")
|> mist.port(8000)

// Starting a supervisor process to watch both
// HTTP server process and the Database Pool
supervisor.new(supervisor.OneForOne)
|> supervisor.add(pog.supervised(db_config))
|> supervisor.add(mist.supervised(mist_builder))
|> supervisor.start
}

Working with SQL

Im really not a fan of using regular strings to store SQL queries, there's no autocomplete, no syntax highlighting, no linting from my editor. Luckily there's a package that takes care of all that for us! Squirrel finds all database queries in your project written in actual .sql files, and generates modules, decoders and functions for you to use. Think of it like being an inverse ORM.

app/
│
├─ src/
│ │
│ ├─ sql.gleam
│ │
│ └─ sql/
│ │
│ ├── get_all_users.sql
│ └── find_user.sql
│
└─ README.md

We can then query our database simply by calling a function. There's no need build complicated decoders and tracking the returned results. One thing you'll hear a lot from the Gleam community is that all you need is data and functions.

// Querying a database might fail, so it returns a `Result`!
let query_result = sql.find_user(db, user_id)

case query_result {
Ok(user) -> send_response(user)
Error(err) -> handle_query_error(err)
}

Encoding Json

The returned rows can easily be encoded into Json and sent to the client through the response body. There's even a LSP code action for it! Just mouse over the type you want to generate an encoder for, and trigger the code action selector by using your IDE.

import gleam/json
// ^^^^
// Remember to add `gleam_json` as a dependency.

type User {
User(name: String, age: Int)
}

pub fn to_json(user: User) -> json.Json {
json.object([
#("name", json.string(user.name)),
#("age", json.int(user.age)),
])
}

Conclusion

This was my first time building a backend project, and if it didn't work as expected, the whole group would be affected. Thankfully we ran into zero runtime errors so far, and all endpoints behaved as intended.

Errors were explicit, data had clear shapes and most error messages felt more like guidance than of a bunch of scrambled text. If you ever need a reliable backend API, I trully recommend you give Gleam a try .