My opinion on enums in TypeScript

Posted on 2023-08-18 in Programmation

Today I'd like to dig a bit on enums in TypeScript and their potential alternatives. For this, I'll be using the playground and TypeScript 5.1.6 (latest released version when I am writing this). I expect you to know what enums are and to be comfortable in TypeScript.

Let's start with a very basic definition and usage of an enum:

enum ProgrammingLanguagesNumberEnum {
    Python,
    TypeScript,
}

const simpleLog = (language: ProgrammingLanguagesNumberEnum) => console.log(language)

// We can use the members.
simpleLog(ProgrammingLanguagesNumberEnum.Python)
// We can use member values directly (by default enums maps to numbers, the first member is 0 and then each member increment by 1).
simpleLog(0)
// We cannot use a value that is not part of the enum.
simpleLog(10)

Note

On previous versions of TypeScript (that is until version 5), simpleLog(10) was allowed and would not be reported as an error.

We can also use specify numbers explicitly:

enum ProgrammingLanguagesExplicitNumberEnum {
    Python = 10,
    TypeScript = 20,
}

const simpleLogBis = (language: ProgrammingLanguagesExplicitNumberEnum) => console.log(language)

simpleLogBis(ProgrammingLanguagesExplicitNumberEnum.Python)
// This yields an error since 0 isn't valid any more.
simpleLogBis(0)
// This works.
simpleLogBis(10)

We can also use strings:

enum ProgrammingLanguages {
    Python = 'Python',
    TypeScript = 'TypeScript',
}

const simpleLogTer = (value: ProgrammingLanguages) => console.log(value)

// Using members directly still works.
simpleLogTer(ProgrammingLanguages.Python)
// This fails since Ruby is not an allowed value of the enum.
simpleLogTer('Ruby')
// This also fails, even if Python is an allowed value.
simpleLogTer('Python')

Now that we've reviewed the basics, let's see why we would use enums:

  1. It's obvious when reading the code that the value belongs to a collection of values. We are not seeing an random 0 or 'Python' but ProgrammingLanguages.Python.

    Note

    Even though we can use member values directly with "number" enums, I think it's a bad idea. I'd even say, the compiler should behave like with string enums and report an error.

  2. We can check that all cases are handled. In the code samples below, if a branch is not covered (or if we add values to the enum), we will get an error:

    function assertNever(value: never): never {
        throw new Error(
            `Unhandled discriminated union member: ${JSON.stringify(value)}`,
        );
    }
    
    const logProgrammingLanguageStringEnum = (language: ProgrammingLanguages) => {
        switch (language) {
            case ProgrammingLanguages.Python:
                console.log('Python');
                break;
            case ProgrammingLanguages.TypeScript:
                console.log('TypeScript')
                break;
            default:
                assertNever(language);
        }
    }
    
    const toEmoji = (language: ProgrammingLanguages): string => {
        switch (language) {
            case ProgrammingLanguages.Python:
            return '🐍'
            case ProgrammingLanguages.TypeScript:
            return '💎'
        }
    }
    
  3. To map enum values to other values. Just like with the previous example, if a value is missing or when we add values to the enum, we will get an error:

    const languagesToEmoji: Record<ProgrammingLanguages, string> = {
        [ProgrammingLanguages.Python]: '🐍',
        [ProgrammingLanguages.TypeScript]: '💎',
    }
    
  4. We can get all the members of the enum like this (it works because once transpiled to JavaScript, enums are just objects, more on that later):

    // We can of course also use Object.values and Object.entries.
    const languages = Object.keys(ProgrammingLanguages);
    

If enums have nice qualities, then why would we want to use something else?

  • We can have those same properties in other ways. So it makes sense to compare the different possibilities before making a choice.

  • TypeScript will generate extra code that must be shipped and executed. So this enum:

    enum ProgrammingLanguages {
        Python = 'Python',
        TypeScript = 'TypeScript',
    }
    

    Will become:

    var ProgrammingLanguages;
    (function (ProgrammingLanguages) {
        ProgrammingLanguages["Python"] = "Python";
        ProgrammingLanguages["TypeScript"] = "TypeScript";
    })(ProgrammingLanguages || (ProgrammingLanguages = {}));
    

    So when we have many enums, their impact won't be negligible both in term of size and execution time.

What are the alternatives?

  1. Plain constants. We define const PYTHON = 'PYTHON' and const TYPESCRIPT = 'TYPESCRIPT'. It works but we loose the fact that these variables are grouped and the compiler cannot notify us about invalid cases in usage. So, not acceptable.

  2. Union types. We can define type Languages = 'Python' | 'TypeScript'. This will still work as expected:

    const languagesToMessage: Record<Languages, string> = {
        Python: '🐍',
        TypeScript: '💎',
    };
    
    const languagesUnionToEmoji = (language: Languages): string => {
        switch (language) {
            case 'Python':
                return '🐍'
            case 'TypeScript':
                return '💎'
        }
    }
    

    However, when reading, it's not obvious that the value comes from a union type and we cannot get all the members programmatically in code (types are compiled away).

  3. Using a const objects. It's probably the alternative that is closer to using enums. But we need an intermediate type to ease its usage:

    const allowedLanguages = {
        Python: 'Python',
        TypeScript: 'TypeScript',
    } as const;
    
    // This is not allowed on a const object. So no risk of adding members by mistake.
    allowedLanguages['Ruby'] = 'Ruby'
    
    // We must extract the exact keys of our object.
    type AllowedLanguages = keyof typeof allowedLanguages;
    
    const allowedLanguagesToMessage: Record<AllowedLanguages, string> = {
        Python: '🐍',
        TypeScript: '💎',
    };
    
    const allowedLanguagesToEmoji = (language: AllowedLanguages): string => {
        switch (language) {
            case 'Python':
                return '🐍'
            case 'TypeScript':
                return ''
        }
    }
    
  4. Using const enum. Instead of declaring our enum like we did, we declare it like this:

    const enum ProgrammingLanguages {
        Python = 'Python',
        TypeScript = 'TypeScript',
    }
    

    This way, it will be compiled away. It's an enum behaving like an enum with a notable exception const languages = Object.keys(ProgrammingLanguages) is triggering an error since it doesn't transpile into an object.

    However, do note that they come with their own set of pitfalls.

If we wrap all this up, what should we use?

  • I think we should use string union types as much as possible to replace enums: they fit nicely into the language and are easy to define and use. On reading, it's a bit harder to see they come from a collection of values, but I think that with habit you can guess when a union type is used (at least that was the case for me). And you editor should be able to help you out. On writing, our editor will complete the strings, so it's not a problem.

    I don't think we should use other union types (like number union types). Reading a plain number won't give enough information to understand what is going on.

  • If you need to access all the members in you code, const objects are a good alternative. They are even recommended in TypeScript documentation!

  • Given the pitfall coming with const enum, I think they should be avoided (and if you don't use them often, you'll probably forget about those pitfalls).

TL;DR: Enums can still be used, but we have better alternatives now. So I think we should use them only sparingly and always start with either a union type or a const object.

Last question: should you migrate away from enums? I think you may but don't need to. They add code to your bundles and may slow code executions, but it this the first thing you must do to improve this? I don't think so. Checking duplicated or big libs will probably contribute more to code bloats than enums. But I guess it depends on your code base and on how much you use enums. It's also easy to do (even if it can be time consuming).

For reference and to help you test what I explained here, here's the full code:

// Basic enums

enum ProgrammingLanguagesNumberEnum {
    Python,
    TypeScript,
}

const simpleLog = (language: ProgrammingLanguagesNumberEnum) => console.log(language)

// We can use the members.
simpleLog(ProgrammingLanguagesNumberEnum.Python)
// We can use member values directly (by default enums maps to numbers, the first member is 0 and then each member increment by 1).
simpleLog(0)
// We cannot use a value that is not part of the enum.
simpleLog(10)

enum ProgrammingLanguagesExplicitNumberEnum {
    Python = 10,
    TypeScript = 20,
}

const simpleLogBis = (language: ProgrammingLanguagesExplicitNumberEnum) => console.log(language)

simpleLogBis(ProgrammingLanguagesExplicitNumberEnum.Python)
// This yields an error since 0 isn't valid any more.
simpleLogBis(0)
// This works.
simpleLogBis(10)



// String enums

enum ProgrammingLanguages {
    Python = 'Python',
    TypeScript = 'TypeScript',
}

const simpleLogTer = (value: ProgrammingLanguages) => console.log(value)

// Using members directly still works.
simpleLogTer(ProgrammingLanguages.Python)
// This fails since Ruby is not an allowed value of the enum.
simpleLogTer('Ruby')
// This also fails, even if Python is an allowed value.
simpleLogTer('Python')

function assertNever(value: never): never {
    throw new Error(
        `Unhandled discriminated union member: ${JSON.stringify(value)}`,
    );
}

const logProgrammingLanguageStringEnum = (language: ProgrammingLanguages) => {
    switch (language) {
        case ProgrammingLanguages.Python:
            console.log('Python');
            break;
        case ProgrammingLanguages.TypeScript:
            console.log('TypeScript')
            break;
        default:
            assertNever(language);
    }
}

const toEmoji = (language: ProgrammingLanguages): string => {
    switch (language) {
        case ProgrammingLanguages.Python:
        return '🐍'
        case ProgrammingLanguages.TypeScript:
        return '💎'
    }
}

logProgrammingLanguageStringEnum(ProgrammingLanguages.Python)
toEmoji(ProgrammingLanguages.TypeScript)

const languagesToEmoji: Record<ProgrammingLanguages, string> = {
    [ProgrammingLanguages.Python]: '🐍',
    [ProgrammingLanguages.TypeScript]: '💎',
}

// We can of course also use Object.values and Object.entries.
const languages = Object.keys(ProgrammingLanguages);



// Exploring alternavites.

type Languages = 'Python' | 'TypeScript'

const languagesToMessage: Record<Languages, string> = {
    Python: '🐍',
    TypeScript: '💎',
};

const languagesUnionToEmoji = (language: Languages): string => {
    switch (language) {
        case 'Python':
            return '🐍'
        case 'TypeScript':
            return '💎'
    }
}

const allowedLanguages = {
    Python: 'Python',
    TypeScript: 'TypeScript',
} as const;

type AllowedLanguages = keyof typeof allowedLanguages;

// This is not allowed on a const object.
allowedLanguages['Ruby'] = 'Ruby'

const allowedLanguagesToMessage: Record<AllowedLanguages, string> = {
    Python: '🐍',
    TypeScript: '💎',
};

const allowedLanguagesToEmoji = (language: AllowedLanguages): string => {
    switch (language) {
        case 'Python':
            return '🐍'
        case 'TypeScript':
            return ''
    }
}

const enum ProgrammingLanguagesConst {
    Python = 'Python',
    TypeScript = 'TypeScript',
}