11 min read

Building a robust permissions system in TypeScript

Banner image

Making it impossible to forget permission checks.


TypeScript Types Guide
Published

Have you ever tried to deal with permissions in your app and run into this issue?

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

AGH! You just forgot to return early from the permission check and ended up trying to write a comment anyways! If you made this mistake on the frontend, you probably got some annoying errors from a backend that hopefully didn’t make the same mistake you did. If you did it on the backend, you just introduced a pretty serious vulnerability in your app. Let’s hope this got caught in code review. Otherwise, you probably caught these hands instead.

How do we prevent this? A bug like this seems impossible to avoid other than by just being very careful; we would need TypeScript to read our minds here if we wanted it to prevent us from doing this, wouldn’t we? Err… well, no, but that might be a planned feature for an upcoming TypeScript update.

We’re making an assumption here, where we believe that all users passed into writeComment are going to have the WRITE_COMMENT permission. The problem is, we’re not telling the typechecker that there’s any difference between an authenticated user who can write comments and a user who’s logged out, so it can’t fail when we use an unauthorized user to do authorized stuff. We’re doing a check with an if statement to gain valuable information about the properties of the data we’re working with, but we’re not turning that information into something the typechecker can use to do its job; the type of user stays the same despite our explicit check. It’s an anti-feature to use a single User type when we’re working with two incompatible ideas, so why not try to come up with a better system that’s capable of representing the data we’re working with more accurately to make sure bugs like this can’t even compile.


We’re going to start by defining our base User, which will represent any user on the site who may or may not be authenticated.

type User = {
  // this is the only relevant property for our system
  permissions: number
  // with any other user-related property
  // you can think of
  name: string
  age: number
}

This specific example uses a bitfield to store the permissions a user can have, which is represented with number. What you use for storing permissions internally is not important. Unless you’re working on the Discord API and want to provide your users a bad experience, you most likely won’t be directly exposing things like bitfields to the consumers of your API anyways. This concept works with any permission system.

Now, let’s define a new AuthorizedUser type to declare a user who’s had a permission check, as we’re going to need a distinction between the two if we want to allow the typechecker to do work for us.

type AuthorizedUser<T extends Partial<Permissions>> = User

Whoa hold up. Why is there a T generic here if it’s not even being used? Shouldn’t you get rid of it?

This type parameter is known as a Phantom Type in Haskell (and probably is a functional programming concept in general). Phantom types are parameters that are not a part of the type definition and are only used to differentiate one type from another based on some criteria to provide additional safety.

The idea is that an AuthorizedUser<T> should always be a valid User but not the other way around. Authorized users are defined with User, making them compatible (or easily convertible in the case of other languages). But User is distinct from AuthorizedUser<T>, so it cannot be used interchangeably with it. Phantom types help us fine-tune this subtyping relationship using the constraint of permissions.

Unfortunately for us, TypeScript does not care that AuthorizedUser has a different name from User because it uses a structural type system. The name you give a type is not taken into account by the typechecker when comparing two types, only the definition itself. If the fields of any two types match, TS considers those two to be compatible. So we’re actually forced to use T in the definition in order to distinguish it from User. You could argue this makes it not a phantom type anymore, but I could argue that you’re dumb and win the argument immediately.

type AuthorizedUser<T extends Partial<Permissions>> = User & {
  // just here to make typescript happy
  __permission__: T
}

This will work, but we can accidentally access __permission__ even though it’s not meant to be used, because it won’t ever be assigned a value. We can use a unique symbol to make sure it’s not accessible in our code by anything other than that symbol, which we will not be exposing to the rest of our codebase.

declare const phantom: unique symbol
type AuthorizedUser<T extends Partial<Permissions>> = User & {
  // just here to make typescript happy
  [phantom]: T
}

It’s worth noting that if you have any properties that should only exist for an authenticated user, you could declare them in this type. There’s even a way to make them only appear for a specific type of permission, but that’s a story for another day.

Now that we’ve declared users, it’s time to head over to the original submitComment function definition.

async function submitComment(user: User, comment: Comment) {
  // your favorite commenting implementation here
}

Let’s change this function by baking the assumptions we’re making about the authorization level of user in our mind directly into the type signature.

async function submitComment(
  user: AuthorizedUser<WriteComment>,
  comment: Comment
) {
  // exact same code as above
}

Remember, nothing changes with the implementation of this function when we change the user type, since an AuthorizedUser<T> is a valid supertype of User. The responsibility of disambiguating between these types is on the consumer, not the provider. As far as we’re concerned inside submitComment, we’re still working with a regular User.

Let’s take a look at the implementation of Permissions and WriteComment.

const writeComment = { writeComment: true } as const
type WriteComment = typeof writeComment

const readComment = { readComment: true } as const
type ReadComment = typeof readComment
type Permissions = WriteComment & ReadComment // & anything else

Huh, this seems like a strange way to do permissions, why not use an enum the way God intended?

Using enums is certainly clearer and easier to read, but it comes with a pretty severe limitation that makes it unusable for our purpose. Consider the following code:

enum Perm {
  WriteComment,
  ReadComment,
  BanUser,
}

function renderCommentsSection(
  user: AuthorizedUser<Perm.WriteComment | Perm.ReadComment>
) {
  // implementation here
}

For this component to be rendered on your app, the user would need to be able to either write comments OR read them. If they can’t do either then there’s no component to even display. Nothing particularly wrong about this case. Enums are just numbers or strings at the end of the day, and this is something that you can easily represent with an enum; it’s just 0 | 1. But what about this case?

function renderCommentsModerationPanel(
  user: AuthorizedUser<Perm.ReadComment & Perm.BanUser>
) {
  // implementation here
}

This is supposed to be a component that renders a control panel for moderators that have the ReadComment AND BanUser permissions but uhhh… what on earth would be the type of 1 & 2? You can have a number that is either a 0 or a 1… but what is a number that is a 0 AND 1, or 1 AND 2? That just doesn’t make any sense. Actually, there is a type for 0 & 2 and it’s called never, not very useful because it just doesn’t exist. You can’t have one number be two different numbers at the same time.

For us to be able to implement permissions, the permissions need to be represented by a type that can be both unioned AND intersected by each other. Sadly that rules out all primitive types including enums if we’re interested in representing more complex permission restrictions. The good news is objects fit this criteria, even if they do feel a little bit more jank than the alternatives.

Now we need a function that allows us to create a new user type and assign it to a new variable to use as an authorized user.

type AuthorizeResult<T extends Permissions> =
  | { type: "ok"; user: AuthorizedUser<T> }
  | { type: "fail"; reason: string }

function authorize<T extends Permissions>(
  user: User,
  permission: T
): AuthorizeResult<T> {
  // imagine an actual function implementation here
  if (someCondition) {
    return { type: "ok", user: user as AuthorizedUser<T> }
  } else {
    return { type: "fail", reason: "User does not stan loona" }
  }
}

There’s a lot going on here, so let’s dissect it piece by piece. We’re defining an authorize function that takes in a regular User and permissions we want to check for, but it’s not returning an AuthorizedUser. The user might not have the correct authorization level, so we’re telling the compiler that the result might either be a successfully authorized user, or a failure with a reason explaining why. The typechecker WILL NOT allow us to access the result of this function without explicitly checking to make sure we successfully got an authorized user out of this.

So the bug we had earlier turns into this with our new system.

function onSubmit() {
  const result = authorize(user, writeComment)
  if (result.type === "fail") {
    sendMessage({
      message: "Sorry you can't write comments!",
    })
    // agh! we forgot to return again
  }
  submitComment(result.user, form.comment)
}

But instead of crashing in runtime, this time we get a compile-time error as soon as we make the mistake.

Property 'user' does not exist on type 'PermissionResult<WRITE_COMMENT>'.

We didn’t return from the if statement and the TypeScript compiler couldn’t narrow the type down to an AuthorizedUser<WRITE_COMMENT>, as soon as we add the missing return it works again. 🎉 We did it!

This still feels a little bit weird, though. Assigning a new user variable feels unnatural, and this doesn’t look like our original check either. That’s ok, TypeScript gives us a way to narrow the types of variables conditionally using something called Type Guards .

Let’s create a type guard for checking permissions.

function hasPermission<T extends SomePermissions>(
  user: User,
  permission: T
): user is AuthorizedUser<T> {
  return Permission.authorize(user, permission).type === "ok"
}

How does the bug look now with the type guard?

function onSubmit() {
  // user has type User here
  if (!hasPermission(user, writeComment)) {
    // user has type User here
    sendMessage({
      message: "Sorry you can't write comments!",
    })
  }
  // user has type User here
  submitComment(user, form.comment)
}

And we still get an error, nice!

Type 'User' is not assignable to type 'AuthorizedUser<WRITE_COMMENT>'

When we return from the if statement properly, we get the same sweet TS typechecker magic.

function onSubmit() {
  // user has type User here
  if (!hasPermission(user, writeComment)) {
    // user has type User here
    sendMessage({
      message: "Sorry you can't write comments!",
    })
    return
  }
  // user has type AuthorizedUser<WRITE_COMMENT> here
  submitComment(result.user, form.comment)
}

It can infer that we’ve narrowed the type of User to the correct AuthorizedUser<T> by performing the check. 👏

This system we set up can work for more complex situations and is capable of dealing with just about any kind of authorization-check-related bug, not just an early return.

Here’s a playground link of a complete implementation if you want to try this out with an editor that displays the correct compiler errors https://tsplay.dev/WK8Zzw.


What we just did here was using a side of static typing that most people don’t even know exists. Yes, static typing can be used to distinguish data apart from each other, but it can also be used to separate incompatible ideas with the same underlying data into different types.

Your job as a programmer should not be to check types in code review. You suck at it; that’s the typechecker’s job. Make it do as much of the work for you as possible because it never makes mistakes. Communicating with your compiler is almost as important as communicating with the people you write the code with.

Gotchas

There are a few caveats to this system, of course. Anyone can cast a User into an AuthorizedUser to circumvent compiler checks. But checking for the presence of unsafe casts and functions with the wrong permission requirements is a much simpler mental task to do in a code review than having to remember to check every permission-related bug every time permissions are involved.

The goal of this is to be able to constrain the slightly unsafe code to just a few functions that are extensively tested, vs being scattered everywhere around a codebase that gets updated with every new commit.