Sunday, September 19, 2021

Choosing Between TypeScript String Literals and Enums

One of the benefits to being part of a development team is that you are exposed to a variety of language constructs, patterns, and coding styles. This can prevent getting stuck in a rut where every problem is a nail that needs hammering. Case in point, I was recently working on an Angular component that had the following type definition:

type TimeFrame = 'hour' | 'day' | 'week' | 'month' | 'year';


I had never seen this particular type before, so I looked it up. That was my introduction to the TypeScript String Literal Union. It’s a lightweight alternative to the string enums that I was accustomed to working with. It may be a better choice than an enum in some circumstances. For that reason, it pays to compare the pros and cons of each, which is exactly what we’ll be doing today.

The Difference Between String enums and String Literal Unions

On the surface, the TimeFrame type above looks a lot like an enum, in that it defines several string constants:

type TimeFrames = 'hour' | 'day' | 'week' | 'month' | 'year';

enum TimeFrames {
    HOUR  = 'hour',
    DAY   = 'day',
    WEEK  = 'week',
    MONTH = 'month',
    YEAR  = 'year'
}

Code Size

One of the main differences can only be observed by looking at the transpiled code. In the case of of unions of string literals, TypeScript doesn’t need to generate code, because it’s only used at compile time. As a result, the generated JavaScript (JS) code will be significantly smaller in size. Contrast that to string enums, which can result in significantly more JS code, as evidenced by the transpiled JS code for the TimeFrames enum:

var TimeFrames;
(function (TimeFrames) {
  TimeFrames["HOUR"] = "hour";
  TimeFrames["DAY"] = "day";
  TimeFrames["WEEK"] = "week";
  TimeFrames["MONTH"] = "month";
  TimeFrames["YEAR"] = "year";
})(TimeFrames || (TimeFrames = {}));

If you don’t mind lowercase values, you can define an enum like this as well:

enum TimeFrames {
  'hour',
  'day',
  'week',
  'month',
  'year'
}

For the convenience of less typing, you wind up with even more verbose JavaScript!

var TimeFrames;
(function (TimeFrames) {
    TimeFrames[TimeFrames["hour"] = 0] = "hour";
    TimeFrames[TimeFrames["day"] = 1] = "day";
    TimeFrames[TimeFrames["week"] = 2] = "week";
    TimeFrames[TimeFrames["month"] = 3] = "month";
    TimeFrames[TimeFrames["year"] = 4] = "year";
})(TimeFrames || (TimeFrames = {}));

Values of String Enums Are Opaque

For all that extra code, you get values that are opaque, meaning that methods that accept that enum type, don’t need to know the exact strings each enum value contains. Indeed, even developers who use the enum don’t need to concern themselves with the actual values. Want a year TimeFrame? Then TimeFrames.YEAR is what you’re looking for. Heck, it could just as easily be a number under the covers. In the case of string enums, you can compare values to other strings, as in:

if (timeFrame === TimeFrames.YEAR) {
  // do something
}

As a bonus, popular Integrated Development Environments (IDEs) like Visual Studio Code can help us choose the value from a list of possible values quickly via auto-complete:

TypeScript Literal String Unions

Contrast that to the union type, where you still have to type the full string whenever you create a new variable, for example:

const TIMEFRAME: TimeFrames = 'year';

Casting Catastrophe

Another, less obvious, difference between TypeScript String Literals and Enums only becomes apparent when attempting to coerce a string into the more specific type. I recently witnessed the disparity when working on an Angular enhancement. Various timeframes were stored as a Union of String Literals; the idea being that they would restrict TimeFrameData, fetched as JSON strings, to valid ones. The issue reared it’s ugly head when I tried to display timeframes in the template using an ngFor loop:

type TimeFrames = 'hour' | 'day' | 'week' | 'month' | 'year';

public readonly TimeFrameData = [
  {
    chartId: 1,
    timeFrame: 'hour',
  },
  {
    chartId: 2,
    timeFrame: 'day',
  },
];

// in the template:
<div *ngFor="let timeFrameData in TimeFrameData">
  <TimeFrameComponent timeFrames={timeFrameData.timeFrame} />;
</div>

To my dismay, the application would not compile! Instead, the compiler complained that type string was not assignable to type TimeFrames. Interestingly, swapping out the String Literals with an enum produced no error!

enum TimeFrames {
  HOUR  = 'hour',
  DAY   = 'day',
  WEEK  = 'week',
  MONTH = 'month',
  YEAR  = 'year'
}

public readonly TimeFrameData = [
  {
    chartId: 1,
    timeFrame: TimeFrames.HOUR,
  },
  {
    chartId: 2,
    timeFrame: TimeFrames.DAY,
  },
];

// in the template:
<div *ngFor="let timeFrameData in TimeFrameData">
  <TimeFrameComponent timeFrames={timeFrameData.timeFrame} />;
</div>

The key was to use enum values in the TimeFrameData object.

Type Guards for Unions of String Literals

Since types are erased at compile time you can’t reference them at all at runtime, including in type guards. As such, the best you can do is retype each value in your validation function. In our application, we we’re combining many properties into one object, so it made sense to use multiple if-else statements.

You can also do something like this:

type TimeFrames = 'hour' | 'day' | 'week' | 'month' | 'year';
type TimeFramesType = {
   [key in TimeFrames ] : any
}
export const TimeFramesObj: TimeFramesType = {
   hour: '',
   day: '',
   week: '',
   month: '',
   year: ''
}
export const isAssignable = (type: string):type is TimeFrames => {
   return (type in TimeFramesObj)
}

           // true,               false
console.log(isAssignable("hour"), isAssignable("dog"));

The problem I have with the above solution is that I just wrote a bunch of code to avoid the extra code that would be generated for an enum!

Conclusion

TypeScript Unions of String Literals look promising, but they still need to evolve before they are truly useful. Until that happens, I would stick with enums, unless the size of your JS codebase trumps all other concerns.

Robert Gravelle
Rob Gravelle resides in Ottawa, Canada, and has been an IT guru for over 20 years. In that time, Rob has built systems for intelligence-related organizations such as Canada Border Services and various commercial businesses. In his spare time, Rob has become an accomplished music artist with several CDs and digital releases to his credit.

Popular Articles

Featured