Modelling Permissions in Gleam

How do you enforce application logic on a compiler level?

Published on: Mon Nov 03 2025

Introduction

In this fantastic article by Xetera (https://xetera.dev/article/typescript-permissions), they come up with a TypeScript implementation to enforce permission levels on a type-level. I find this a very interesting use case for types, to enforce application and business logic on that level. Since I am an avid Gleam user, I wanted to port this example to the language.

Here is the web archive link to the article, in case it goes down: https://web.archive.org/web/20250708105241/https://xetera.dev/article/typescript-permissions/

You shall not pass. Unless I mess up.

If you do not know Gleam yet, let me introduce you: gleam reader, reader gleam. The elevator pitch for this language is that it is statically typed and compiles to both JavaScript and Erlang, making it possible to create anything from Websites to Backends with the same language. You can watch these videos, if you want to learn more:

I am not going to go into the details of the implementation in the original article. If you care about the TypeScript implementation, I am not going to make it justice here. I will however quickly go over the issue, where this approach is coming from.

In TypeScript, and a lot of other languages, you might have code that looks like this:

function onSubmit() {
  if (!user.hasPermission(Permission.WRITE_COMMENT)) {
    sendMessage({ message: "Sorry you can't post comments!", })
  }
  submitComment(user, form.comment)
}

This is a big oopsie. If you missed it, there is no return statement after sendMessage, so the permission is not actually enforced. After climate change, this is probably the biggest concern keeping up young developers at night. This is an issue I personally have run into in production, so I can definitely relate that one would want to get a grip on this. So what if this error could be caught at compile time. Not at runtime, when it is potentially too late.

Meet the competition

We will now build a similar case in Gleam. Let us imagine a world, where developers make mistakes. Ok, well I guess we can just imagine reality, as it is. You have a nice thing going in your application:

pub type AppError {
  PermissionDenied
}

pub type Permission {
  Readonly
  Admin
}

pub type User {
  User(admin: Bool)
}

pub fn get_permission(user: User) -> Permission {
  case user {
    User(admin: False) -> Readonly
    User(admin: True) -> Admin
  }
}

pub fn is_admin(user: User, next: fn() -> a) -> Result(a, AppError) {
  case get_permission(user) {
    Readonly -> Error(PermissionDenied)
    Admin -> Ok(next())
  }
}

// placeholder for an actual frontend component
pub fn form(user: User) {
  use <- is_admin(user)
  "foo"
}

// placeholder for an actual frontend component
pub fn home(user: User) {
  Ok("bar")
}

pub fn main() {
  // this would come from a database in a real world application
  let user = User(admin: False)

  echo case get_permission(user) {
    Readonly -> {
      form(user)
    }
    Admin -> {
      form(user)
    }
  }
}

Code on Gleam Playground

You can clearly identify how the form function is guarded against incorrect permissions. Life is good. You run gleam build and get this message:

$ gleam build
  Compiling app
  Compiled in 0.24s

All is well. Or so you think. UNTIL (!). A few hours later bug reports are pouring in, that normal users are unable to access your website because you are rendering the incorrect component in their view.

pub fn main() {
  let user = User(admin: False)
  echo case get_permission(user) {
    Readonly -> { // <- oops, not an admin
      form(user)  // <- the stuff they make nightmares out of
    }
    Admin -> {
      form(user)
    }
  }
}

You shall not pass. After I compiled successfully.

So how do we make sure we catch this when compiling our application and not when it might already be too late?

We are going to make sure to tag our user with a phantom type. “Phantom”, because the generic over our custom type is not actually used in any of the properties. But they sort of paint the record with anything we assign to it. Kind of like a hall pass, but with less… paint. Ok maybe that analogy did not hold up.

pub type User(is) {
  User(admin: Bool)
}

pub type IsUnknown

pub type IsReadonly

pub type IsAdmin

// we need to tag the user with `IsUnknown` since
// no authorization of any kind has taken place
pub fn new_user(admin: Bool) -> User(IsUnknown) {
  User(admin:)
}

pub fn authorize_admin(user: User(a)) -> Result(User(IsAdmin), Nil) {
  case user {
    User(admin: False) -> Error(Nil)
    User(admin: True) -> {
      let User(admin:) = user // <- make the compiler happy, dont worry about it
      Ok(User(admin:))
    }
  }
}

// placeholder for an actual frontend component
pub fn form(user: User(IsAdmin)) {
  "foo"
}

// placeholder for an actual frontend component
pub fn home(user: User(a)) {
  "bar"
}

pub fn main() {
  // this would come from a database in a real world application
  let user = new_user(False)

  echo case authorize_admin(user) {
    Error(Nil) -> {
      form(user)
    }
    Ok(user) -> {
      form(user)
    }
  }
}

Code on Gleam Playground

Trying to build the same application again:

$ gleam build
error: Type mismatch
   ┌─ /src/main.gleam:38:12

38       form(user)
            ^^^^

Expected type:

    User(IsAdmin)

Found type:

    User(IsUnknown)

We have basically created a ticketing system, where you first have to authenticate the user and are only allowed to use certain components requiring some set of permissions when you are on the happy path. In a real world application, you would put everything related to User and authentication in a dedicated module, make the types opaque and provide builder functions to stay in control of the logic. Otherwise an unfortunate future junior developer soul might be tempted to commit typing sins that will land you angry customers, or worse: in a primeagen video.

It is also noteworty, that this in no means guarantees no issues with permissions in your application. If you mess up the authorize_admin function, everything following that will suffer equally. But we can at least reduce the points where things can go wrong and catch them as early as possible.

Closing Thoughts

I’m gonna be honest, this article was no easy task. It took a while for all of the pieces to click. Opaque Types, Phantom Types. How to coerce a type in Gleam from User(a) -> User(IsAdmin) (it was a journey: https://discord.com/channels/768594524158427167/1434989606696128652). Maybe this is what having to learn Monad in Haskell feels like? Food for thought.

Thank you to the gleam core team that is always there to help me out and explain my questions with patience. Here are more resources to check out, if you are interested in these topics:

Stay safe out there. Write more Gleam. Cheers!