Skip to Content
Course content

241: Modeling Permissions with Literal Union Types

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you're building an app with different user levels—like a CMS or a project management tool—your first instinct might be to just use a string for the user's role. I've seen this in countless code reviews. It seems easy, but it's a recipe for runtime bugs that are a nightmare to track down.

Why bother with literal unions instead of just using string?

If you define a role as type Role = string, TypeScript is happy as long as you provide any text. But in the real world, you don't have "any" role; you have a specific set of them. If you accidentally type "admn" instead of "admin" in a permission check, a string type won't blink. Your user just won't be able to access the dashboard, and you'll spend an hour debugging why the if statement is failing.

By using a literal union, you're telling TypeScript that the value isn't just any string—it's exactly one of these specific strings. Check this out:

type UserRole = 'admin' | 'editor' | 'viewer';

interface User {
  id: string;
  role: UserRole;
}

const user: User = {
  id: 'u123',
  role: 'admn' // ❌ Error: Type '"admn"' is not assignable to type 'UserRole'.
};

Now the compiler is acting as your safety net. It catches the typo before the code even leaves your editor. I love this because it turns a potential production bug into a red squiggly line in VS Code.

How do I actually use these to restrict access in my functions?

The real magic happens when you combine these unions with TypeScript's ability to "narrow" types. Since the compiler knows exactly which strings are possible, it can help you write logic that is logically sound.

Let's say we have a function that handles deleting a document. Only admins and editors should be able to do that. You don't need fancy validation libraries here; a simple switch or if will do:

function canDeleteDocument(role: UserRole): boolean {
  switch (role) {
    case 'admin':
    case 'editor':
      return true;
    case 'viewer':
      return false;
  }
}

Notice how I didn't need a default case here. Because UserRole is a union of those three specific strings, TypeScript knows that once you've handled 'admin', 'editor', and 'viewer', there's nothing left. If you try to return something outside that logic, TS will let you know.

How can I stop myself from forgetting a role when the app grows?

Here is where things get interesting. Imagine your boss comes to you in three months and says, "We need a moderator role." You add 'moderator' to your UserRole union, but you forget to update the canDeleteDocument function. Suddenly, moderators are hitting a wall (or worse, getting unintended access).

To prevent this, I use a pattern called "exhaustive checking" with the never type. It's a bit of a pro move, but it's incredibly powerful:

function canDeleteDocument(role: UserRole): boolean {
  switch (role) {
    case 'admin':
    case 'editor':
      return true;
    case 'viewer':
      return false;
    default:
      // This ensures that if a new role is added to the union, 
      // this line will throw a compile-time error.
      const _exhaustiveCheck: never = role;
      return _exhaustiveCheck;
  }
}

By assigning role to a variable of type never in the default block, you're telling TypeScript: "I expect to have handled every single possibility by the time I get here." If you add 'moderator' to the union, role is no longer never—it's 'moderator'—and the code won't compile until you add a case for it. It basically forces you to be thorough.




📋 Practical Task

Exercise: Implementing a File System Access Guard

You are building a cloud storage application. You need to implement a permission system that handles three roles: 'owner', 'contributor', and 'guest'.

Your Task:

  1. Create a literal union type called AccessLevel containing the three roles mentioned above.
  2. Create a function called getPermissions that takes an AccessLevel as an argument and returns a string description of what that user can do:
    • 'owner': "Full access: read, write, and delete."
    • 'contributor': "Limited access: read and write."
    • 'guest': "Read-only access."
  3. Implement an exhaustive check using the never type in the default case of your switch statement to ensure that any future roles added to AccessLevel must be handled in this function.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.