HomeSpeaking
 
  

Comparing TypeScript's union types, enums and const enums

May 20, 2021

✨ This blog post was originally posted as a Twitter thread. ✨

TypeScript playground


Compile time vs run time

Only enums result in JS output after compilation. Others are dropped during the compile time.

(See, that for all examples here, union types will have "U" suffix, similarly to enums with "E", and const enums with "CE".)

// TypeScript code
type RemoteDataU
= 'NotAsked'
| 'Loading'
| 'Success'
| 'Failure'
enum RemoteDataE {
NotAsked = 'NotAsked',
Loading = 'Loading',
Success = 'Success',
Failure = 'Failure'
}
const enum RemoteDataCE {
NotAsked = 'NotAsked',
Loading = 'Loading',
Success = 'Success',
Failure = 'Failure'
}
// The same TS code compiled to JS
var RemoteDataE;
(function (RemoteDataE) {
RemoteDataE["NotAsked"] = "NotAsked";
RemoteDataE["Loading"] = "Loading";
RemoteDataE["Success"] = "Success";
RemoteDataE["Failure"] = "Failure";
})(RemoteDataE || (RemoteDataE = {}));

Iterating through values

Only enums can be iterated through, because this case is, in fact, related to the previous one. If there's no value at runtime, we cannot iterate through it.

for (const data in RemoteDataE) {
console.log("remote data from enum: ", data);
}
// But you can do something like that with the union...
const remoteData = ['NotAsked', 'Loading', 'Sucess', 'Failure'] as const;
type RD = typeof remoteData[number]; // 'NotAsked' | 'Loading' | 'Success' | 'Failure'
// You can iterate now
for (const data of remoteData) {
console.log("remote data from union: ", data);
}

Switch-case and matching values

Non-exhaustive matching on either of the three results in error when --strictNullChecks compiler option is on.

// Error: Function lacks ending return statement
// and return type does not include 'undefined'.
function switchE(state: RemoteDataE): string {
switch (state) {
case RemoteDataE.NotAsked:
return "NotAsked";
}
}
// Error: Function lacks ending return statement
// and return type does not include 'undefined'.
function switchU(state: RemoteDataU): string {
switch (state) {
case 'NotAsked':
return "NotAsked";
}
}
// Error: Function lacks ending return statement
// and return type does not include 'undefined'.
function switchCE(state: RemoteDataCE): string {
switch (state) {
case RemoteDataCE.NotAsked:
return "NotAsked";
}
}

Numeric values

Enums are numeric by default (if you don't assign any value to its members). You can assign any number to the numeric enum.

There's no problem with constraining numeric values with union types.

type MetricU = 0 | 1 | 2 | 3 | 4;
enum MetricE {
Zero, One, Two, Three, Four
}
const enum MetricCE {
Zero, One, Two, Three, Four
}
const exampleMetricU: MetricU = 0;
// @ts-expect-error: Type '17' is not assignable to type 'MetricU'.
const exampleMetricU2: MetricU = 17;
const exampleMetricE: MetricE = MetricE.Zero;
const exampleMetricE2: MetricE = 17; // No error
const exampleMetricCE: MetricCE = MetricCE.Zero;
const exampleMetricCE2: MetricCE = 17; // No error

Extending

Unions can be extended by declaring a "union of union types". One way to extend an enum or const enum is to convert them to a type by creating a sum.

type AnotherU = 'AnotherVal';
enum AnotherE {
Another = 'AnotherVal'
}
const enum AnotherCE {
Another = 'AnotherVal'
}
type ExtendedU = RemoteDataU | AnotherU;
type ExtendedE = AnotherE | RemoteDataE
type ExtendedCE = AnotherCE | RemoteDataCE
const exampleExtendedU: ExtendedU = "NotAsked";
const exampleExtendedE: ExtendedE = RemoteDataE.NotAsked;
const exampleExtendedCE: ExtendedCE = RemoteDataCE.NotAsked;

Another way to achieve sum of enums (source):

type ExtendedExtra = RemoteDataE | AnotherE;
const ExtendedExtra = { ...RemoteDataE, ...AnotherE };
const a: ExtendedExtra = ExtendedExtra.Another;

Impact of refactoring

Imagine changing value assigned to the union or enum in a large codebase.

With union, you'd have to check all places impacted by the change and update those places to match new value of the union.

Pro: compiler guides you along the way and gives a full view of the impact of the change and if it makes sense everywhere.


Con: may take some time.

With enum, you'd apply the change just on the value of enum's member. This result in no compiler errors (except when you change the "left-hand" side, the enum member itself).

Pro: change is instant, takes almost no time


Con: losing the ability to see the scope of the change


Template Literal Types

TLT can be created only as a union type (docs).

(Although, enums can be used as operands for template literal types. But see, that they are produced based on the value assigned to a member, not the enum member itself.)

type TemplateLiteral1 = `${RemoteDataU} ${AnotherU}`
type TemplateLiteral2 = `${RemoteDataE} ${AnotherE}`
type TemplateLiteral3 = `${RemoteDataCE} ${AnotherCE}`

Structural vs nominal types

Enum is an example of a nominal type (enums are not equal if they differ by name). Unions represent structural types (if literal values match, they are equal).

Even if value of literal value matches, you can't pass it to a function accepting enum.

function computeE(state: RemoteDataE) {
// do sth
}
function computeU(state: RemoteDataU) {
// do sth
}
function computeCE(state: RemoteDataCE) {
// do sth
}
computeE(RemoteDataE.NotAsked); // OK
// @ts-expect-error: Argument of type '"NotAsked"' is not assignable
// to parameter of type 'RemoteDataE'.
computeE('NotAsked');
computeU('NotAsked');
computeCE(RemoteDataCE.NotAsked); // OK
// @ts-expect-error: Argument of type '"NotAsked"' is not assignable
// to parameter of type 'RemoteDataCE'.
computeCE('NotAsked');

Accessing and auto-completion.

All give you auto-completion in code editors.

Enums need to be imported in order to use them in other modules. You need to type the name of the enum first in order to access its member. Using a value from union is like using any literal value.


Support for other transpilers (e.g. Babel)

Unions and enums pose no problems when using with different transpilers. On the other hand, const enums may cause some, as written here.


Documenting

/**
* Example doc for union type.
* See that you can place a doc for the type, but not for the members
* separately.
*/
type YetAnotherU = 'YetAnother';
/**
* Example doc for enum.
* See that you can place docs for both the enum itself and all its
* members.
*/
enum YetAnotherE {
/** Example doc for YetAnother enum member. */
YetAnother = 'YetAnother'
}
/**
* Example doc for const enum.
* See that you can place docs for both the const enum itself and all
* its members.
*/
const enum YetAnotherCE {
/** Example doc for YetAnother const enum member. */
YetAnother = 'YetAnother'
}
Kajetan Świątek

Kajetan Świątek

Front-end Developer, coding nerd, learning-new-stuff lover.

© 2023 | Created by Kajetan Świątek with Gatsby