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

Enter Gleam

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

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 pattern matching. You can define it at the top level scope and easily switch on all possible values.

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

The compiler even warns you when you're missing one or more branches!

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.

pub fn start(
  handler: fn(wisp.Request) -> wisp.Response,
) -> Result(actor.Started(supervisor.Supervisor), actor.StartError) {
  let mist_child =
    wisp_mist.handler(handler, wisp.random_string(54))
    |> mist.new
    |> mist.bind("0.0.0.0")
    |> mist.port(8000)
    |> mist.supervised

  supervisor.new(supervisor.OneForOne)
  |> supervisor.add(mist_child)
  |> supervisor.start
}

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.

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()
  }
}

Working with SQL

I'm 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.

 app/
│
├─  priv/
├─  dev/
│
├─  src/
│  │
│  ├─  sql.gleam
│  │
│  └─  sql/
│     │
│     ├──  get_all_users.sql
│     ├──  find_user.sql
│     └──  get_all_occurrences.sql
│
└─   README.md

We can then query our database simply by calling a function. There's no need to set up an ORM, build complicated decoders or do anything fancy. 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(found_user) -> send_response(found_user)
  Error(err) -> handle_query_error(err)
}

The returned results 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)
}

fn user_to_json(user: User) -> json.Json {
  let User(name:, age:) = user
  json.object([
    #("name", json.string(name)),
    #("age", json.int(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.