Wringing IT

TypeScript Enum Composition

2020-02-24

The following is the result an investigation into a recurring issue with enums in TypeScript. The problem at hand was: how do I make enums out of other enums clearly, concisely and without needless repetition? TypeScript's type system is getting more advanced with every version so surely there must be a way to do that?

Classes, interfaces etc.

Pretty much every type in TypeScript can be composed or extended. In classes and interfaces there's the extends keyword:

interface Basic {
  field: string;
}

interface Extended extends Basic {
  extension: number;
}

class Base {}

class Child extends Base {}

class Composition extends Base implements Extended {
  field = 'placeholder';
  extension = 0;
}

Open in TypeScript Playground

Type Aliases can be either composed or made into a union using the & and | operators respectively:

type SomeType = {
  count: number;
}

type AnotherType = {
  description: string;
};

type IntersectionType = SomeType & AnotherType;

type UnionType = SomeType | AnotherType;

Open in TypeScript Playground

Extending enums

But what about enums? Turns out it's not that easy. There's no language construct that would enable us to extend them like classes or interfaces. So how does one work around that?

Most of the following was inspired by this GitHub issue.

Runtime composition

Enums are special, because in their default form they exist both at compile time and at runtime(similarly to classes1) - as types in the former and Plain Old JavaScript Objects in the latter case - with some exceptions). Given that, they can be treated like any other object:

enum Move {
  LEFT = 'Left',
  RIGHT = 'Right',
  FORWARD = 'Forward',
  BACKWARD = 'Backward'
}

const myMove = {
  ...Move,
  JUMP: 'Jump'
}

Open in TypeScript Playground

But not without caveats:

// The operand of a delete operator cannot be a read-only property. (2704)
delete Moves.BACKWARD;

Which often leads us to solutions that don't type-check too well:

const NoTurns = Object
  .keys(Move)
  .filter(item => item === Move.FORWARD || Move.BACKWARD);

if (Object.values(NoTurns).includes(Move.LEFT)) {
  // Unreachable point, but the compiler doesn't know that.
}

Open in TypeScript Playground

Or are just awkward:

const Turns = {
  LEFT: Move.LEFT,
  RIGHT: Move.RIGHT
}

// Property 'FORWARD' does not exist on type '{ LEFT: Move; RIGHT: Move; }'. (2339)
const nextMove = Turns.FORWARD;

Open in TypeScript Playground

Not necessarily what we're looking for.

Compile-time composition

Given that enums are types, normal type composition rules apply:

enum Tree {
  OAK = 'Oak',
  PINE = 'Pine',
  POPLAR = 'Poplar'
}

enum Flower {
  DAISY = 'Daisy',
  POPPY = 'Poppy',
  TULIP = 'Tulip'
}

type Plant = Tree | Flower;

const a: Plant = Flower.DAISY;
const b: Plant = Tree.OAK;

Open in TypeScript Playground

Although not every such construct makes sense:

type Impossibility = Tree & Flower; // type Impossibility = never

Open in TypeScript Playground

The problem which remains is that Plant is not an enum, which automatically limits its use cases. Same thing happens when using utility types:

enum Fruit {
  BANANA = 'Banana',
  WATERMELON = 'Watermelon',
  STRAWBERRY = 'Strawberry',
  RASPBERRY = 'Raspberry'
}

// type Berry = Fruit.BANANA | Fruit.WATERMELON
type Berry = Extract<Fruit, Fruit.BANANA | Fruit.WATERMELON>;

// type NotBerry = Fruit.STRAWBERRY | Fruit.RASPBERRY
type NotBerry = Exclude<Fruit, Fruit.BANANA | Fruit.WATERMELON>;

// These are strange times for berry club. Strange times...

Open in TypeScript Playground

One-by-one composition of course also works:

enum Turns {
  LEFT: Move.LEFT,
  RIGHT: Move.RIGHT
}

But again, not without caveats. Having the source and target enum in separate files one could be tempted to use propety shorthands and destructuring:

import { Fruit } from './fruit.ts';

// All destructured elements are unused.(6198)
const { BANANA, WATERMELON, STRAWBERRY, RASPBERRY } = Fruit;

export enum Berry {
  BANANA, // (enum member) Berry.BANANA = 0
  WATERMELON
}

Open in TypeScript Playground

Ok, so shorthands don't work. Perhaps adding assignments would fix this issue?

import { Fruit } from './fruit.ts';

const { BANANA, WATERMELON, STRAWBERRY, RASPBERRY } = Fruit;

// Computed values are not permitted in an enum with string valued members.(2553)
export enum Berry {
  BANANA = BANANA,
  WATERMELON = WATERMELON
}

Open in TypeScript Playground

Turns out that this is a design limitation of TypeScript. const only refers to the value's runtime behaviour. The compiler doesn't go into the specifics of how that value was originally assigned.

Numeric enums

The last question in the issue mentioned earlier was not addressed, so I'll take a stab at it. Notice how so far we've been only discussing string enums? There's a good reason for that. Numeric enums by design accept any number in assignment:

enum Fruit {
  BANANA,
  WATERMELON,
  STRAWBERRY,
  RASPBERRY
}

const tomato: Fruit = 74; // This is fine.

Open in TypeScript Playground

Does this mean that numeric enums are fundamentally non-type-safe? Not necessarily. Numeric enums have reverse mappings, so it's possible to easily check at runtime if a given number belongs to a certain enum:

enum Fruit {
  BANANA,
  WATERMELON,
  STRAWBERRY,
  RASPBERRY
}

if (Fruit[74]) {
    // Unreachable code
}

Open in TypeScript Playground

Uses

We know now that it's currently not possible to extend enums directly. But are any of the aforementioned workarounds useful in any way? Yes, in discriminated unions.

Enums don't exist in a vacuum - they're often used as e.g. fields in data structures. Take for example a select input with a few options:

const enum Fruit {
  BANANA = 'Banana',
  WATERMELON = 'Watermelon',
  STRAWBERRY = 'Strawberry',
  RASPBERRY = 'Raspberry'
}

interface Option {
  label: string;
  value: Fruit;
}

const fruit: Array<Option> = [
  {
    label: 'Banana!',
    value: Fruit.BANANA
  },
  {
    label: 'Watermelon!',
    value: Fruit.WATERMELON
  },
  {
    label: 'Strawberry!',
    value: Fruit.STRAWBERRY
  },
  {
    label: 'Raspberry!',
    value: Fruit.RASPBERRY
  },
];

Open in TypeScript Playground

We can make it type check for berries by using the Extract utlity type:

type Berry = Extract<Fruit, Fruit.BANANA | Fruit.WATERMELON>;

interface Option {
  label: string;
  value: Berry;
}

Open in TypeScript Playground

The error:

// Type 'Fruit.STRAWBERRY' is not assignable to type 'Fruit.BANANA | Fruit.WATERMELON'.(2322)

shows, that we're indeed allowing only berries.

Conclusion

Overall, at least until #17592 is closed, TypeScript does not offer any way to extend or compose enums, barring the workarounds explored in this article. Fortunately those are usually enough for common use cases.

1The default compilation target for TypeScript is ES5, while JS classes were introduced in ES2015. Where available TypeScript will compile TS classes to their JS equivalents.