TypeScript's Literal Types Are Better Than Enums

Combining the descriptive qualities of enumerated types and the easy-to-use nature of strings.

During a code review, something that sticks out when the code is full of ad-hoc string checking. For example, implementing a blog engine with three types of roles can start with a check if the user is an admin, then a check for the editor role, and of course, we can have registered and unregistered users.

interface User {
    type: string;
}

function deleteUser(user: User) {
    if (user.type === 'admin') {
        //... make the call to delete a user
    } else {
        // ... show some error of the user does not having the permission to do this
    }
}

function createArticle(user: User) {
    if (user.type === 'admin' || user.type === 'editr') {
        // navigate the user to the editor page
    } else {
        // show some error of the user not having the permission to do this
    }
}

Does the job, but there are two major problems with this approach.

  • Type safety - if we check strings, it is super important that the value cannot have any value. In the example above, I mistyped the type editor and there is a good chance I would realize only when I open the app as an editor, and I cannot create articles. Would be nice if the compiler could point out this tiny but essential mistake.
  • Lack of documentation - Good code documents itself, as the saying goes. While this rule-of-thumb is only valid to some extent and is often used as an excuse for not having any documentation at all, the code can undoubtedly do a better job here. We might ask, what type of roles do we have? Indeed, there is an admin and an editor (or editr?), but is there another? It's hard to say and requires searching through the whole codebase to find out.

Enum to the rescue

There is no new magic here, as this issue in most programming languages was solved decades ago.

enum UserType {
    Admin = 'admin',
    Editor = 'editor',
    Reader = 'reader',
    Anonymous = 'anonymous'
}

interface User {
    type: UserType;
}

function deleteUser(user: User) {
    if (user.type === UserType.Admin) {
        //... make the call for delete
    } else {
        // ... show some error that the user does not have the permission to do this
    }
}

function createArticle(user: User) {
    if (user.type === UserType.Admin || user.type === UserType.Editor) {
        // navigate the user to an editor
    } else {
        // show some error that the user does not have the permission to do this
    }
}

The problem is solved; I cannot mistype the editor role anymore, and I can also see what types of users I can expect. The code is as clean as it can be. However, there are still some nuances.

  • Notice that in the enum definition, we had to directly assign the string values like this: Admin = admin; otherwise, Admin would have the value 0. Not a big deal, but we had to write down the word admin exactly one time more than we wanted to.
  • If we usually work on projects with multiple files, we need to import this enum every time we want to read or write the user type value. One line of import statement is again, not a big deal, but if we want to do it an Angular component's template, then we also need to assign the enum to a member of the component.
import { Component, Input, OnInit } from '@angular/core';
import { UserType } from '../user-type.enum';
import { User } from '../user.model';

@Component({
  selector: 'app-user',
  template: `
    <div *ngIf="user.type === UserType.Admin">Admin panel</div>
    <div *ngIf="user.type === UserType.Editor">Editor panel</div>
    <div *ngIf="user.type === UserType.Reader">Reader panel</div>
    <div *ngIf="user.type === UserType.Anonymous">Anonymous panel</div>
    `,
})
export class UserComponent {
  @Input() user: User;
  UserType = UserType;
}
Stackblitz link here

Literal types have the best of two worlds

Let's take a step back and use strings again, but this time define the user type as a unioned string literal type.

interface User {
    type: 'admin' | 'editor' | 'reader' | 'anonymous';
}

function deleteUser(user: User) {
    if (user.type === 'admin') {
        //... make the call for delete
    } else {
        // ... show some error that the user does not have the permission to do this
    }
}

function createArticle(user: User) {
    if (user.type === 'admin' || user.type === 'editor') {
        // navigate the user to an editor
    } else {
        // show some error that the user does not have the permission to do this
    }
}

So the only difference to the first example is that we define the user type as a set of possible values.

  • If we mistype the editor role, the TypeScript compiler sends us a telling error: "This condition will always return 'false' since the types '"editor" | "reader" | "anonymous"' and '"editr"' have no overlap."
  • The type definition is also more straightforward, as we had to write each role's name only once.
  • Since we use plain strings, we don't have to define the user type as a public member of our component.
import { Component, Input } from '@angular/core';
import { User } from '../user.model';

@Component({
  selector: 'app-user',
  template: `
    <div *ngIf="user.type === 'admin'">Admin panel</div>
    <div *ngIf="user.type === 'editor'">Editor panel</div>
    <div *ngIf="user.type === 'reader'">Reader panel</div>
    <div *ngIf="user.type === 'anonymous'">Anonymous panel</div>
    `,
})
export class UserComponent {
  @Input() user: User;
}

But does the Angular compiler recognize a typo as the TypeScript compiler did? Yes, and it also writes the same error;

This condition will always return 'false' since the types '"admin" | "editor" | "reader" | "anonymous"' and '"editr"' have no overlap.

It is not a coincidence; the Angular compiler converts component templates to plain typescript code.

+1: How to list all the possible values of a unioned literal type

As JavaScript does not have a native enum type, the TypeScript compiler converts it into a plain JavaScript object.

var User;
(function (User) {
    User[User["Admin"] = 0] = "Admin";
    User[User["Editor"] = 1] = "Editor";
    User[User["Reader"] = 2] = "Reader";
    User[User["Anonymous"] = 3] = "Anonymous";
})(User || (User = {}));

On the other hand, literal types are not mapped to any JavaScript structure, and that concept ends with the TS-to-JS compilation. But what if, for any reason, we dynamically want to list all the possible user roles? With enum being compiled to an object, it is straightforward; iterate trough the object's keys.

It is possible to do the same with the literal types too.

export const UserTypes = ['Admin', 'Editor', 'Reader', 'Anonymous'] as const;
export type UserType = typeof UserTypes[number];

interface User {
	type: UserType;
}
Can find more about this in the TypeScript handbook

Conclusion

Enums in TypeScript are as powerful as in any other language, but with TypeScript's support for unioned literal types, it is possible to have all the compile-time benefits of the enums without the need to import an enum every time.