Type-Safe Routing in Gleam

Published on: Sun Apr 27 2025

Introduction

On my journey of exploring the world beyond TypeScript and JavaScript Frameworks, I have unexpectedly come to appreciate and miss a lot of things from that development environment.

One of those things is having typesafety everywhere. The ecosystem around this in the TypeScript world is truly amazing. trpc, nuqs, T3 Env, TanStack Router, TanStack Form, Drizzle … Everything in your application can be typesafe. Your SQL, your environment variables, web forms, routing, etc.

For me, typesafety ultimately is about confidence. Confidence that you did not forget the DATABASE_URL in your .env file, you forgot to update any href in an anker tag or forgot to update the name of a field in your form.

There are of course mechanisms outside of types to deal with these: documentation, search & replace, automated & manual tests. But the point of putting these concerns into the type system is to have this guaranteed by a compiler. Running tsc --noEmit in a TanStack Router application and getting a green successfull is a form of confidence that only an extensive test suite can give you, which is a form of overhead and work of its own. So typesafety guarantees in a way feel free. Not that it is, but compared to other solutions, for all intents and purposes it might aswell be.

Ok so if moving forward we can pretend like putting as much in the compiler’s hand is a good thing, we can talk about today’s project: typesafe routing in gleam!

Why Gleam and why Routing

If you are not familiar with Gleam yet, checkout this talk: A Code Centric Journey Into the Gleam Language • Giacomo Cavalieri • YOW! 2024

As far as “why routing”, it is quite simple. There is a sentiment in gleam to keep things simple. Functions and data, that’s all you need. Want to render HTML? We’re not going to build a template engine, we’ll just use functions. Typesafe SQL? Instead of introducing metaprogramming or other DSLs, let’s just do some code-gen (reference to squirrel).

Here is a paragraph from the lustre documentation, thats sums it up pretty well:

You might be wondering “where are the templates?” Lustre doesn’t have a separate templating syntax like JSX or HEEx for a few reasons:
  • Gleam does not have a macro system or a way to define new syntax, so a templating language would exist outside of Gleam. Gleam’s language server and compiler errors are some of the best around, and adding a templating language on top would mean attempting to map those to a different language while maintaining a great developer experience.

  • Templating languages hide the fact that your ui is made of just functions. This is a powerful realization, because all of the same patterns you might use to abstract or organize your code also apply to your UI: pass UI-generating functions as arguments, return them from other functions, partial apply them, and so on!

If you’re initially put off by the lack of templating syntax, we encourage you to stick with it for a while and see how you get on. Gleam is at its best when you’re writing simple, functional code, and that carries over to your ui as well.

I have become a bit obsessed with type safety and doing as much analysis as possible at compile and build time as to reduce as many runtime errors as possible. My impression is that outside of the TypeScript world, typesafety that goes into all aspects of an application does not seem to be as big of a deal. Sure I can spin up an application with Python, Django, HTMX and some JavaScript. But personally, I am going to suffer greatly in a couple months when I come back to that project and need to do any form of change. Raw strings, no static types, … literal nightmare fuel.

Routing is just Math

Routing is an interesting candidate for typesafety, because the underlying mechanism is deceptively simple. It is basically a mapping of String A to String B in both directions. So first, how do we make sure we only link pages in our application that are covered in the router. Then secondly, how do we know what routes we can link? Ideally we can just autocomplete our way to success in the HTML and get immediate feedback in the compiler when we link a page, that is not covered in the router yet.

Given this HTML:

<a href="/settings">Settings</a>

We want to guarantee this:

case wisp.path_segments(req) {
  ["settings"] -> settings_page()
}

So if there is a <a href="/profile">Profile</a>, but there is no profile page served by the router, we want the compiler to inform us and not let us run the application until we’ve implemented it.

There are additional concerns with coming up with a solution, that we will not cover today: how is the developer experience? Is there any code-gen we need to make sure to do beforehand? Are there performance implications for the router? How does this scale for 100+ routes?

Also before we start any new project, we should first look at what is already available out there. Unfortunately searching through some gleam packages, does not yield any results that offer what we want. Packages like hayleigh-dot-dev/modem or Billuc/gleatter are going into the right direction, but are not fully end-to-end typesafe.

Level 1

Instead of showing off the ultimate router solution, we are going to work our way up the thought chain.

A straight forward approach to improve typesafety (which won’t be end-to-end yet) is to use custom types to define your routes and create helper functions to convert between them and route segments.

Here is the code:

pub type Route {
  Home
  Profile(id: String)
}

pub fn route_to_path(route: Route) {
  case route {
    Home -> "/"
    Profile(id) -> "/profile/" <> id
  }
}

pub fn segs_to_route(segs: List(String)) -> Result(Route, Nil) {
  case segs {
    [] -> Ok(Home)
    ["profile", id] -> Ok(Profile(id))
    _ -> Error(Nil)
  }
}

pub fn route_to_html(route: Route) -> String {
  case route {
    Home -> home()
    Profile(id) -> profile(id)
  }
}

pub fn handler(segs: List(String)) -> String {
  case segs_to_route(segs) {
    Ok(route) -> {
      route_to_html(route)
    }
    Error(_) -> "404"
  }
}

pub fn home() -> String {
  todo as "home page html"
}

pub fn profile(_id: String) -> String {
  todo as "profile page html"
}

Gleam Playground

This has the advantage of forcing us to handle new routes added to the Route type. It also lets us handle navigation in a typesafe manner, because when you remove a route, it will throw an error and the path of the route is handled in a central place.

When you add a new route, you will get screamed at by the compiler like so:

error: Inexhaustive patterns
   ┌─ /src/main.gleam:8:3

 8   case route {
 9     Home -> "/"
10     Profile(id) -> "/profile/" <> id
11   }
 ╰───^

This case expression does not have a pattern for all possible values. If it
is run on one of the values without a pattern then it will crash.

The missing patterns are:

    NewRoute

error: Inexhaustive patterns
   ┌─ /src/main.gleam:23:3

23   case route {
24     Home -> home()
25     Profile(id) -> profile(id)
26   }
 ╰───^

This case expression does not have a pattern for all possible values. If it
is run on one of the values without a pattern then it will crash.

The missing patterns are:

    NewRoute

However this approach is missing one crucial aspect: ensuring that the route_to_path and segs_to_route functions match the same URL pattern. This is valid code that compiles:

pub fn route_to_path(route: Route) {
  case route {
    Home -> "/"
    Profile(id) -> "/profile/" <> id
  }
}

pub fn segs_to_route(segs: List(String)) -> Result(Route, Nil) {
  case segs {
    [] -> Ok(Home)
    ["profiles", id] -> Ok(Profile(id))
    _ -> Error(Nil)
  }
}

But contains a typo in the /profile/:id route. So there is still manual work here to not mess up.

Level 2

Ok but what if we do not want 2 sources of truth for the URL strings. No problem, this is a fairly quick fix. All we need to do is implement roundtrip tests. So for each route we first generate the path with our route_to_path function, convert it into segments and pass those back to the segs_to_route function to verify that we get the same Route object, that we got the path from. That way, if there is a typo, we get a failing test:

Compiling routing
 Compiled in 0.23s
  Running routing_test.main
F
Failures:

1) routing/level_one_test.roundtrip_test: module 'routing@level_one_test'
   Values were not equal
   expected: Ok(Profile("1"))
        got: Error(Nil)
   output:

Finished in 0.007 seconds
1 tests, 1 failures

Here is the test code:

import gleam/uri
import gleeunit/should
import routing/level_one

pub fn roundtrip_test() {
  level_one.route_to_path(level_one.Home)
  |> uri.path_segments()
  |> level_one.segs_to_route
  |> should.equal(Ok(level_one.Home))

  let profile = level_one.Profile("1")
  level_one.route_to_path(profile)
  |> uri.path_segments()
  |> level_one.segs_to_route
  |> should.equal(Ok(profile))
}

The only catch is that we need to make sure that we implement a test for every route. Also if there are more complex path configurations, for example with search queries, we might need to implement more extensive tests.

Here is more complex code that also handles a search query with multiple entries:

import gleam/int
import gleam/list
import gleam/result
import gleam/uri

pub type CampaignsSearch {
  CampaignsSearch(rows: Int, page: Int)
}

pub type Route {
  Home
  Profile(id: String)
  Campaigns(search: CampaignsSearch)
}

pub fn route_to_path(route: Route) {
  case route {
    Home -> "/"
    Profile(id) -> "/profile/" <> id
    Campaigns(search) -> {
      let CampaignsSearch(rows, page) = search
      let rows = int.to_string(rows)
      let page = int.to_string(page)
      let search = uri.query_to_string([#("rows", rows), #("page", page)])
      "/campaigns/" <> search
    }
  }
}

pub fn segs_to_route(segs: List(String)) -> Result(Route, Nil) {
  case segs {
    [] -> Ok(Home)
    ["profile", id] -> Ok(Profile(id))
    ["campaigns", search] -> {
      use search <- result.try(uri.parse_query(search))

      use rows <- result.try(list.find(search, fn(s) { s.0 == "rows" }))
      use page <- result.try(list.find(search, fn(s) { s.0 == "page" }))

      use rows <- result.try(int.parse(rows.1))
      use page <- result.try(int.parse(page.1))

      Ok(Campaigns(CampaignsSearch(rows, page)))
    }
    _ -> Error(Nil)
  }
}

pub fn route_to_html(route: Route) -> String {
  case route {
    Home -> home()
    Profile(id) -> profile(id)
    Campaigns(search) -> campaigns(search)
  }
}

pub fn handler(segs: List(String)) -> String {
  case segs_to_route(segs) {
    Ok(route) -> {
      route_to_html(route)
    }
    Error(_) -> "404"
  }
}

pub fn home() -> String {
  todo as "home page html"
}

pub fn profile(_id: String) -> String {
  todo as "profile page html"
}

pub fn campaigns(_search: CampaignsSearch) -> String {
  todo as "campaigns page html"
}

Gleam Playground

And a modified test suite:

import gleam/list
import gleam/uri
import gleeunit/should
import routing/level_two

pub const test_routes: List(level_two.Route) = [
  level_two.Home,
  level_two.Profile("1"),
  level_two.Campaigns(level_two.CampaignsSearch(3, 5)),
]

pub fn roundtrip_test() {
  list.each(test_routes, fn(route) {
    level_two.route_to_path(route)
    |> uri.path_segments()
    |> level_two.segs_to_route
    |> should.equal(Ok(route))
  })
}

Level 3

This is getting pretty good. To be honest level 2 is already something that I would use in production. But what if we could do even better!

It is important to mention that with level 2, we have reached the limits of what is possible with only the typesystem of gleam. Because we cannot acccess any information about how many variants of a custom type exist at compile- or runtime, it is impossible to guarantee that we cover all cases without manual oversight to some extend. But not to worry, this is where code-gen comes in, which is quite easy to do in gleam.

So basically we are going to introduce an additional build step into our program, which you can add to your Makefile or bash script to ensure it is run every time you run or build your gleam project.

There are many opportunities of where to introduce our code-gen. We could generate the segment generation based on or we could build off our existing test suite and ensure we test every type variant there. Although the former option is more complex, that latter is definitely more prone to errors since we could miss an edge case regarding path parameter or search queries.

So how is our route definition going to look like and what are we going to generate?

We are going to use the glance package to parse our router definitions. We are also going to use simplifile for working with paths and files, justing for extra string functionality and filepath for… filepath stuff.

The way we’ll define our routes is incredibly simple. It is quite literally just a csv file with the format alias, path, module name, function name

All we will do is go line by line and generate helper functions around each line that contains the name alias, the path with potential arguments and the gleam module name and function that the handler is located at to generate the HTML.


The code generation function:
import gleam/lisimport filepath
import gleam/list
import gleam/string
import gleam/uri
import justin
import simplifile

pub type PathSegment {
  Literal(val: String)
  Param(name: String)
}

pub type RouterDefinition {
  RouterDefinition(
    alias: String,
    path: List(PathSegment),
    module: String,
    handler: String,
  )
}

pub fn path_to_segments(path: String) -> List(PathSegment) {
  path
  |> uri.path_segments()
  |> list.map(fn(seg) {
    case seg {
      "$" <> param -> Param(param)
      val -> Literal(val)
    }
  })
}

pub fn main(router_definitions: String, output_path: String) {
  let lines =
    router_definitions
    |> string.trim()
    |> string.split("\n")
    |> list.map(string.trim)

  let definitions =
    lines
    |> list.map(fn(line) {
      let assert [alias, path, module, handler] =
        line
        |> string.split("|")
        |> list.map(string.trim)
      let path = path_to_segments(path)
      let alias = justin.pascal_case(alias)
      RouterDefinition(alias, path, module, handler)
    })

  let gen_imports =
    definitions
    |> list.map(fn(def) { "import " <> def.module })
    |> list.unique()
    |> string.join("\n")

  let type_variants =
    definitions
    |> list.map(fn(def) {
      let params =
        def.path
        |> list.map(fn(seg) {
          case seg {
            Literal(_) -> ""
            Param(name) -> name <> ": String"
          }
        })
        |> list.filter(fn(s) { s != "" })
        |> string.join(", ")

      "  " <> def.alias <> "(" <> params <> ")"
    })
    |> string.join("\n")
  let gen_type_route = "pub type Route {\n" <> type_variants <> "\n}"

  let route_to_html_cases =
    definitions
    |> list.map(fn(def) {
      let params =
        def.path
        |> list.map(fn(seg) {
          case seg {
            Literal(_) -> ""
            Param(name) -> name
          }
        })
        |> list.filter(fn(s) { s != "" })
        |> string.join(", ")

      "    "
      <> def.alias
      <> "("
      <> params
      <> ") -> "
      <> def.handler
      <> "("
      <> params
      <> ")"
    })
    |> string.join("\n")
  let gen_route_to_html =
    string.trim(
      "pub fn route_to_html(route: Route) -> String {\n"
      <> "  case route {\n"
      <> route_to_html_cases
      <> "\n  }\n"
      <> "}",
    )

  let route_to_path_cases =
    definitions
    |> list.map(fn(def) {
      let params =
        def.path
        |> list.map(fn(seg) {
          case seg {
            Literal(_) -> ""
            Param(name) -> name
          }
        })
        |> list.filter(fn(s) { s != "" })
        |> string.join(", ")

      let path =
        def.path
        |> list.map(fn(seg) {
          case seg {
            Literal(val) -> "\"" <> val <> "/\""
            Param(name) -> name
          }
        })
        |> string.join(" <> ")
      let path = case path {
        "" -> "\"/\""
        path -> "\"/\" <> " <> path
      }

      "    " <> def.alias <> "(" <> params <> ") -> " <> path
    })
    |> string.join("\n")
  let gen_route_to_path =
    string.trim(
      "pub fn route_to_path(route: Route) -> String {\n"
      <> "  case route {\n"
      <> route_to_path_cases
      <> "\n  }\n"
      <> "}",
    )

  let segs_to_route_cases =
    definitions
    |> list.map(fn(def) {
      let params_left =
        def.path
        |> list.map(fn(seg) {
          case seg {
            Literal(val) -> "\"" <> val <> "\""
            Param(name) -> name
          }
        })
        |> string.join(", ")

      let params_right =
        def.path
        |> list.map(fn(seg) {
          case seg {
            Literal(_) -> ""
            Param(name) -> name
          }
        })
        |> list.filter(fn(s) { s != "" })
        |> string.join(", ")

      let params_right = case params_right {
        "" -> ""
        params_right -> {
          "(" <> params_right <> ")"
        }
      }

      "    ["
      <> params_left
      <> "]"
      <> " -> "
      <> "Ok("
      <> def.alias
      <> params_right
      <> ")"
    })
    |> string.join("\n")
  let gen_segs_to_route =
    string.trim(
      "pub fn segs_to_route(segs: List(String)) -> Result(Route, Nil) {\n"
      <> "  case segs {\n"
      <> segs_to_route_cases
      <> "\n    _ -> Error(Nil)\n"
      <> "  }\n"
      <> "}",
    )

  let generated_code =
    gen_imports
    <> "\n\n"
    <> gen_type_route
    <> "\n\n"
    <> gen_segs_to_route
    <> "\n\n"
    <> gen_route_to_html
    <> "\n\n"
    <> gen_route_to_path

  let output_dir = filepath.directory_name(output_path)
  let _ = simplifile.create_directory_all(output_dir)
  let _ = simplifile.write(output_path, generated_code)

  Ok(Nil)
}

Our route definitions:
home    | /            | routing/level_three | level_three.home
profile | /profile/$id | routing/level_three | level_three.profile

The router file it generates:
import routing/level_three

pub type Route {
  Home()
  Profile(id: String)
}

pub fn segs_to_route(segs: List(String)) -> Result(Route, Nil) {
  case segs {
    [] -> Ok(Home)
    ["profile", id] -> Ok(Profile(id))
    _ -> Error(Nil)
  }
}

pub fn route_to_html(route: Route) -> String {
  case route {
    Home() -> level_three.home()
    Profile(id) -> level_three.profile(id)
  }
}

pub fn route_to_path(route: Route) -> String {
  case route {
    Home() -> "/"
    Profile(id) -> "/" <> "profile/" <> id
  }
}

So yeah it basically generated the code we already had in level 1, but without the need for roundtrip tests, since we don’t have to worry about the route_to_path and segs_to_route function to be out of sync.

Here are some ideas on how to extend this code:

  • include possibility to define a search query
  • generally find edge cases and more testing around this
  • add possibility to use integer parameters with a syntax like /profile/$id:int

Summary

Another possibility would of course be to implement file routing. Similar to how you can define file components in lustre, I am sure some sort of file routing á la NextJS or TanStack Router would be kind of cool to implement.

But anyways, I am quite happy with the final code-gen tool. Maybe gleam will add some sort of runtime type reflection or meta programming in the future by which we can make some code-gen obsolete. But for now, this will do.

Happy Coding!