Home > Software engineering >  In typescript, why is a union of enums not also an enum?
In typescript, why is a union of enums not also an enum?

Time:02-25

I have two enums that I would like to union.

enum Color {
    RED = 'red',
    BLUE = 'blue',
}

enum Shape {
    CIRCLE = 'circle',
    SQUARE = 'square',
}

However when I declare a union type and attempt to reference a member:

type ColorShape = Color | Shape;
const test: ColorShape = ColorShape.RED;

I get a compile time error:

TS2693: 'ColorShape' only refers to a type, but is being used as a value here.

Why is this? Would an union of enums not also be an enum itself?

CodePudding user response:

It is important to keep in mind the difference in TypeScript between TypeScript types, which exist at compile time and are erased from the emitted JavaScript code; and JavaScript values, which also exist at runtime and appear in the emitted JavaScript code. TypeScript tries to keep track of values and types, but JavaScript only knows about values.

Most of the time when you make a declaration in TypeScript you are either bringing a value into scope or a type into scope but not both:

// values
const foo = 123;
function bar() { }

// types
type Baz = { a: number };
interface Qux { b: string }

In the above, foo and bar are values that exist in the emitted JavaScript, while Baz and Qux are types that are erased.

And note that values and types have completely different namespaces; you can have a value and a type with the same name, and they don't have to be related to each other. The compiler doesn't confuse them:

// separate namespaces
const Xyz = { z: 1 };
type Xyz = { y: string };

Here Xyz is the name of a value (an object with a z property whose value is 1) and also the name of a type (an object type with a y property whose value type is string). These are unrelated. If I write const uvw = Xyz; then I'm writing value-level code that makes it to JavaScript and therefore the Xyz is the value. If I write interface Rst extends Xyz { } then I'm writing type-level code that's erased from JavaScript and therefore the Xyz is the type.


Okay, so most of the time when you declare some named thing, that thing is either a type or a value but not both. But there are two declarations that do bring into existence both a value and a type of the same name: the class declaration and the enum declaration:

// both
class Abc { c = "" }
enum Def { D = "e" }

Here the name Abc refers to both a class constructor value that you can reference at runtime like new Abc(), and it also refers to a class instance type that you can use at compile time to talk about class instances like const instance: Abc = .... And if you write const instance: Abc = new Abc() you are referring first to the type and then to the value.

Similarly, the name Def refers to both an enum object value that you can reference at runtime like Def.D === "e", and it also refers to an enum type that is the union of the types of the enum member values like const def: Def = .... And if you write const def: Def = Def.D you are referring first to the type and then to the value.


Now, while class is part of JavaScript (since ES2015), enum is not. So when you write an enum the compiler will essentially downlevel it to some JavaScript. Let's look at your Color enum:

enum Color {
    RED = 'red',
    BLUE = 'blue',
}

This more or less compiles to something that acts like

const Color = {
  RED: "red",
  BLUE: "blue"
}

It's just a plain object. Meanwhile the type Color is something that acts like

type Color = "red" | "blue";

That's not strictly true because enums are "marked" so that you can't just assign a string to them, but it's close enough for our purposes. So when your enum Color declaration is similar to the combination of the above value and type declarations.


Now, when you write

type ColorShape = Color | Shape;

You are creating a ColorShape type. But type ColorShape = ... is not an enum, and so all you've done is create the type. There is no corresponding value brought into existence here. You've only done half the work in creating a combined enum-like thing. If you want the corresponding value, you will have to create it manually.

What you want is something that looks like {RED: "red", BLUE: "blue", CIRCLE: "circle", SQUARE: "square"}. There are multiple ways to take the Color value and the Shape value and make such an object.

You could use the Object.assign() method:

const ColorShape = Object.assign({}, Color, Shape);
// const ColorShape: typeof Color & typeof Shape

Or you could use object spread:

const ColorShape = { ...Color, ...Shape };
/* const ColorShape: {
    CIRCLE: Shape.CIRCLE;
    SQUARE: Shape.SQUARE;
    RED: Color.RED;
    BLUE: Color.BLUE;
} */

Either way should work. Once you've done this, now, you can (mostly) use ColorShape as if it were a combined enum composed of Color and Shape:

const test: ColorShape = ColorShape.RED; // okay

Note that I said "mostly". The enum declaration also brings a few more types into scope; it actually acts as a namespace where you can use the dotted path to enum values as types themselves:

interface RedState {
    name: string;
    color: Color.RED // <-- this is a type
}

But that doesn't happen with either of your ColorShape definitions:

interface RedState {
    name: string;
    color: ColorShape.RED // error!
    // 'ColorShape' only refers to a type, but is being used as a namespace here.
}

You probably don't care about that. If somehow you do, then the only way I know how to do this is to manually create your ColorShape namespace and export each type from it:

namespace ColorShape {
    export type CIRCLE = typeof ColorShape.CIRCLE;
    export type SQUARE = typeof ColorShape.SQUARE;
    export type RED = typeof ColorShape.RED;
    export type BLUE = typeof ColorShape.BLUE;
}

Not great, and probably not anything you need. But for completeness I figured I'd just show that there's a bit more going on under the hood with enums.

Playground link to code

  • Related