Enforcing Compile-Time Permission Checking with TypeScript Brands

Most applications with user accounts will need to do permission checking. Of course, it’s important to include all of the appropriate testing to ensure that only users with the correct permissions can perform certain actions. But what if we also had a way to explicitly tell anyone making calls to restricted functions that a particular level of permission is required?

Protecting Yourself With Types

One of the benefits of using TypeScript is that you can find plenty of ways to leverage the type system to help protect you from making poor choices. If you need to be particularly careful, there are a several ways to utilize nominal typing techniques to make otherwise-identical types incompatible. This is often referred to as branding.

Our application is built on top of the Atomic SPA Starter Kit, which comes with this handy definition of a Brand type. I’ll be using this type in the remainder of the example.


/** Create a "branded" version of a type. TypeScript will disallow any value that does not have the same brand. */
export type Brand<T, BrandT> = T & Branding<BrandT>

/** Used by Brand to mark a type in a readable way. */
interface Branding<BrandT> {
  _type: BrandT;
}

Creating Branded Permissions Types

So, now we have an easy way to create some formally branded types. How might we use that to write a type for a permission? In a permissions module, we can define a type like this:


export type PermissionToModifyUser = Brand<UserId, "Permission to modify a user account">;

Here, we’re saying, “This type represents the ID of a user that I have permission to modify.” The UserId in the Brand could have been any other type–a simple number, a string, or even a Boolean. In our application, UserId is only a type defined to represent a number that came from a user’s ID column in the database.

Using Permissions in Service Functions

Having a shiny new permission type is pretty neat, but it doesn’t help much until it’s used somewhere. So, how might we use it? Let’s pretend that we have some sort of a service set up for users. It might even live in a cleverly-named user-service module.

This UserService provides different functions for working with user records. One of those functions is the one we need for modifying an existing record. That modify function is where we will use this permission type.


export function modifyUser(userId: PermissionToModifyUser, modifyArgs: any): any {
  // Modify the user in some way
}

By using the PermissionToModifyUser type on the userId argument, this function is telling everyone who calls it, “Careful, a special permission is needed to do this!”

Now, anyone calling modifyUser would know to make a permission check before the function can be called. Again, additional testing is important for ensuring correct runtime behavior, but this branding can help bring common developer errors to the surface at compile time.

Generating the Permissions

We have our fancy permission type and we use it in the modify function so developers know what’s expected. However, that still doesn’t prevent someone from adding as PermissionToModifyUser to just any UserId.

To discourage this, we established a pattern of calling into permission checking functions that live in the permissions module. Those functions will either return a permission brand (when the user can perform the action) or null (when they cannot).


export function canModifyUser(currentUser: User, userIdToModify: UserId): PermissionToModifyUser | null {
  // Do some check on the currentUser to see if they should be able to
  // modify the user whose ID was passed in
  if(currentUserHasPermission) {
    // If they have permission, brand userIdToModify with the permission type
    return userIdToModify as PermissionToModifyUser;
  } else {
    // Otherwise, just return null
    return null;
  }
}

With this pattern defined, any part of the application that needs a permission could use those functions to generate it. We often make these types of checks in our GraphQL mutation resolvers. In its simplest form, a permission check will look something like this:


...

// currentUser is the User object we loaded from the current application context
// userIdToModify could be passed in by the client initiating the modification
const permission = canModifyUser(currentUser, userIdToModify)

if(permission) {
  // The current user has permission, continue to modify the user as desired
  UserService.modifyUser(permission, modifyArgs);
} else {
  // Handle error state when the currentUser doesn't have the right permission
}

...

Real-World Use

As mentioned before, this is not a full runtime solution for ensuring the right permission criteria are met. Even if you use these branded permission types, don’t forget to appropriately test your application for desired runtime behavior.

With that limitation in mind, finding ways to make the type system work for you and your team can be extremely powerful. The more you can protect yourself from silly mistakes, the better!

Conversation
  • Anthony says:

    Using branding for type-checked permission enforcement is really clever.
    This pattern is exactly what I was looking for. Thanks for the article Laura!

  • Comments are closed.