Have you ever tried to deal with permissions in your app and run into this issue?
Typescript
1function onSubmit() {2 if (!user.hasPermission(Permission.WRITE_COMMENT)) {3 sendMessage({4 message: "Sorry you can't post comments!",5 })6 }7 submitComment(user, form.comment)8}
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.
Typescript
1type User = {2 // this is the only relevant property for our system3 permissions: number4 // with any other user-related property5 // you can think of6 name: string7 age: number8}
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.
Typescript
1type 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.
Typescript
1type AuthorizedUser<T extends Partial<Permissions>> = User & {2 // just here to make typescript happy3 __permission__: T4}
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.
Typescript
1declare const phantom: unique symbol2type AuthorizedUser<T extends Partial<Permissions>> = User & {3 // just here to make typescript happy4 [phantom]: T5}
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.
Typescript
1async function submitComment(user: User, comment: Comment) {2 // your favorite commenting implementation here3}
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.
Typescript
1async function submitComment(2 user: AuthorizedUser<WriteComment>,3 comment: Comment4) {5 // exact same code as above6}
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
.
Fun side-note
If you're working in a team, using a declarative approach like this also makes the capabilities of this function and your intentions crystal clear to your coworkers. What is a restriction for typecheckers is often documentation for humans. đ
Letâs take a look at the implementation of Permissions
and WriteComment
.
Typescript
1const writeComment = { writeComment: true } as const2type WriteComment = typeof writeComment34const readComment = { readComment: true } as const5type ReadComment = typeof readComment6type 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:
Typescript
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?
Typescript
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.
Typescript
1type AuthorizeResult<T extends Permissions> =2 | { type: "ok"; user: AuthorizedUser<T> }3 | { type: "fail"; reason: string }45function authorize<T extends Permissions>(6 user: User,7 permission: T8): AuthorizeResult<T> {9 // imagine an actual function implementation here10 if (someCondition) {11 return { type: "ok", user: user as AuthorizedUser<T> }12 } else {13 return { type: "fail", reason: "User does not stan loona" }14 }15}
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.
Typescript
1function onSubmit() {2 const result = authorize(user, writeComment)3 if (result.type === "fail") {4 sendMessage({5 message: "Sorry you can't write comments!",6 })7 // agh! we forgot to return again8 }9 submitComment(result.user, form.comment)10}
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.
Typescript
1function hasPermission<T extends SomePermissions>(2 user: User,3 permission: T4): user is AuthorizedUser<T> {5 return Permission.authorize(user, permission).type === "ok"6}
How does the bug look now with the type guard?
Typescript
1function onSubmit() {2 // user has type User here3 if (!hasPermission(user, writeComment)) {4 // user has type User here5 sendMessage({6 message: "Sorry you can't write comments!",7 })8 }9 // user has type User here10 submitComment(user, form.comment)11}
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.
Typescript
1function onSubmit() {2 // user has type User here3 if (!hasPermission(user, writeComment)) {4 // user has type User here5 sendMessage({6 message: "Sorry you can't write comments!",7 })8 return9 }10 // user has type AuthorizedUser<WRITE_COMMENT> here11 submitComment(result.user, form.comment)12}
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.