Stop Misusing Typescript String Enums

Introduction

String enums were a featured that was introduced in version 2.4. Since then, we have preferred using string enums over numeric enums because it better demonstrate the developer's intent. However, there a severe drawbacks to the usage of string enums; strong-typing being one of them. This has been detrimental to the Typescript developer experience because tools outside of an individual developer control such as software development kits and code generation tools are filled with string enums.

In this post I will teach you about the problems enums were meant to solve and its drawbacks. I will demonstrate how constant objects and discriminated unions work and how they can be used as an alternative to string enums.

The Problem Enums Were Meant to Solve

Imagine you had two sets of values. One for credit card type: VISA, Mastercard, AMEX and one for shipping type: Standard, Express, Next-Day. Logically, we can group these sets in two separate enums.

enum CreditCardType {
  VISA,
  MASTERCARD,
  AMEX
}

enum ShippingType {
  STANDARD,
  EXPRESS,
  NEXTDAY
}

Each enum member has an underlying sequential value. In this case, it ranges from 0-2. Hence, what would happen if we were to compare VISA to STANDARD? Both have an underlying value of 0. It must follow that they are equal. Or so we may think.

In a statically typed language, the compiler would prevent this comparison. It would be illogical to compare a credit card type with a shipping type. Hence, every enum must have its own unique type. From an object-oriented perspective, you can consider every enum to be a class.

String Enums

String enums are a considerable upgrade from numerical enums. This is because it gives the underlying data type a comprehensible value. It also makes debugging easier because it is identifiable in a print statement.

Here is what the two enums above would look like as string enums.

enum CreditCardType {
  VISA = 'VISA',
  MASTERCARD = 'MASTERCARD',
  AMEX = 'AMEX'
}


enum ShippingType {
  STANDARD = 'STANDARD',
  EXPRESS = 'EXPRESS',
  NEXTDAY = 'NEXTDAY'
}

If we were to compare VISA to STANDARD again, they would not be equal because the underlying values are different. However, a statically typed compiler will still enforce the enum invariant. The comparison remains impossible. The illogical nature of comparing a credit card type to a shipping type has not changed.

Drawbacks of String Enums

If strong typing was a benefit for numeric enums, it is huge drawback for string enums.

Imagine you are using an SDK provided by a third party for their shipping service. They provide an interface using the ShippingType enum above. For business reasons, you only want your customers to specify standard or express shipping. You decide to create you own enum for your web application controller. Because your enum is narrower than the SDK's enum, you expect there to be no problem passing it straight the SDK.

namespace FastShippersCo {
  export enum ShippingType {
    STANDARD = 'STANDARD',
    EXPRESS = 'EXPRESS',
    NEXTDAY = 'NEXTDAY',
  }

  export interface ShippingService {
    ship(type: ShippingType): void
  }
}

namespace TShirtsOnline {
  enum OrderShippingType {
    STANDARD = 'STANDARD',
    EXPRESS = 'EXPRESS',
  }

  class ShippingDetailsDTO {
    type!: OrderShippingType
  }

  declare const shippingService: FastShippersCo.ShippingService
  declare const shippingDetails: ShippingDetailsDTO

  // Argument of type 'OrderShippingType' is not assignable to parameter of type 'ShippingType'. ts(2345)
  // highlight-next-line
  shippingService.ship(shippingDetails.type)
}

Argument of type OrderShippingType is not assignable to parameter of type ShippingType.

The purpose of strong typing was to prevent accidents when comparing numeric enums. There are no accidents for string enums. The developer's intent is always clear. The compiler error here is a real pain to deal with because it causes unnecessary obstruction. Compounded with the fact that there are no easy ways around it.

Casting is not a solution.

shippingService.ship(shippingDetails.type as FastShippersCo.ShippingType)

Conversion of type OrderShippingType to type ShippingType may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to unknown first. ts(2352)

Mapping works, but it is highly verbose.

switch (shippingDetails.type) {
  case OrderShippingType.STANDARD:
    shippingService.ship(FastShippersCo.ShippingType.STANDARD)
  case OrderShippingType.EXPRESS:
    shippingService.ship(FastShippersCo.ShippingType.EXPRESS)
}

Discriminated Unions

A great alternative to string enums are discriminated unions. The primary difference between a string enum and a discriminated union is the lack of strong typing. This is exactly what we want.

export type ShippingType = 'STANDARD' | 'EXPRESS' | 'NEXT_DAY'

There is a problem with this approach. We cannot directly reference the type without using a string literal. We want to preserve the ability to do this:

const shippingType = FastShippersCo.ShippingType.STANDARD

What we could also do is also export an object containing the enums values and extract the type based on the enum's values. Remember we can export a type and an object with the same name because types are erased after transpilation.

export const ShippingType = {
  STANDARD: 'STANDARD' as 'STANDARD',
  EXPRESS: 'EXPRESS' as 'EXPRESS',
  NEXTDAY: 'NEXTDAY' as 'NEXTDAY',
}

export type ShippingType =
  | typeof ShippingType.STANDARD
  | typeof ShippingType.EXPRESS
  | typeof ShippingType.NEXTDAY

We can make a minor adjustment using as const to prevent code duplication.

export const ShippingType = {
  STANDARD: 'STANDARD',
  EXPRESS: 'EXPRESS',
  NEXTDAY: 'NEXTDAY',
} as const

export type ShippingType =
  | typeof ShippingType.STANDARD
  | typeof ShippingType.EXPRESS
  | typeof ShippingType.NEXTDAY

Finally, we can leverage keyof and typeof to turn our type into a one liner.

export const ShippingType = {
  STANDARD: 'STANDARD',
  EXPRESS: 'EXPRESS',
  NEXTDAY: 'NEXTDAY',
} as const

export type ShippingType = typeof ShippingType[keyof typeof ShippingType]

To understand how this works, first evaluate keyof typeof ShippingType. This is an expansion of all the keys in ShippingType.

'STANDARD' | 'EXPRESS' | 'NEXTDAY'

For each one of these values, it indexes the ShippingType object to get the following.

typeof ShippingType['STANDARD'] | typeof ShippingType['EXPRESS'] | typeof ShippingType['NEXTDAY']

This is what the final type looks like when we hover our mouse over it.

type FastShippersCo.ShippingType = 'STANDARD' | 'EXPRESS' | 'NEXTDAY'

Conclusion

Never use string enums. Always favour discriminated unions. Code generation tools, especially those written in other programming languages must be aware of this fact. Adoption of these tools will increase as more developers adopt OpenAPI standards. SDK designers and library designers must also take notice.

No Comments Yet