Skip to main content

0025 - Deprecate TypeScript Enum Types

ID:ADR-0025
Status:PROPOSED
Published:2025-05-30

Context and Problem Statement

TypeScript experts have long discouraged the use of enums. There are a variety of cited reasons. Among them:

  • Enums that are not const substantially increase output size.
  • Numeric enums implicitly cast from number to the enum type, which allows invalid enum values to be transmitted to functions.
  • String enums are named types that require a member is used, which is inconsistent with the behavior of numeric enums.

These inconsistencies cause increased complexity from guard statements in the best of cases. In the worst cases, their limitations may be unknown, and thus unguarded.

Detection

TypeScript deprecation can be linted using a fairly short ESLint plugin. The code has already been contributed to main and is configured as an error-level lint. The same PR adds FIXME comments for each team to address.

The Enum-like Pattern

In most cases, enums are unnecessary. A readonly (as const) object coupled with a type alias avoids both code generation and type inconsistencies.

// declare the raw data and reduce repetition with an internal type
const _CipherType = {
Login: 1,
SecureNote: 2,
Card: 3,
Identity: 4,
SshKey: 5,
} as const;

type _CipherType = typeof _CipherType;

// derive the enum-like type from the raw data
export type CipherType = _CipherType[keyof _CipherType];

// assert that the raw data is of the enum-like type
export const CipherType: Readonly<{ [K in keyof _CipherType]: CipherType }> =
Object.freeze(_CipherType);

This code creates a type CipherType that allows arguments and variables to be typed similarly to an enum. It also strongly types the const CiperType so that direct accesses of its members preserve type safety. This ensures that type inference properly limits the accepted values to those allowed by type CipherType. Without the type assertion, the compiler infers number in these cases:

const s = new Subject(CipherType.Login); // `s` is a `Subject<CipherType>`
const a = [CipherType.Login, CipherType.Card]; // `a` is an `Array<CipherType>`
const m = new Map([[CipherType.Login, ""]]); // `m` is a `Map<CipherType, string>`
warning

Considered Options

  • Allow enums, but advise against them - Allow enum use to be decided on a case-by-case basis by the team that owns the enum. Reduce the level of the lint to a "suggestion".
  • Deprecate enum use - Allow enums to exist for historic or technical purposes, but prohibit the introduction of new ones. Reduce the lint to a "warning" and allow the lint to be disabled.
  • Eliminate enum use - This is the current state of affairs. Prohibit the introduction of any new enum and replace all enums in the codebase with typescript objects. Prohibit disabling of the lint.

Decision Outcome

Chosen option: Deprecate enum use

Positive Consequences

  • Allows for cases where autogenerated code introduces an enum by necessity.
  • Developers receive a warning in their IDE to discourage new enums.
  • The warning can direct them to our contributing docs, where they can learn typesafe alternatives.
  • Our compiled code size decreases when enums are replaced.
  • If all teams eliminate enums in practice, the warning can be increased to an error.

Negative Consequences

  • Unnecessary usage may persist indefinitely on teams carrying a high tech debt.
  • The lint increased the number of FIXME comments in the code by about 10%.

Plan

  • Update contributing docs with patterns and best practices for enum replacement.
  • Update the reporting level of the lint to "warning".

Appendix A: Mapped Types and Enum-likes

Mapped types cannot determine that a mapped enum-like object is fully assigned. Code like the following causes a compiler error:

const instance: Record<CipherType, boolean> = {
[CipherType.Login]: true,
[CipherType.SecureNote]: false,
[CipherType.Card]: true,
[CipherType.Identity]: true,
[CipherType.SshKey]: true,
};

Why does this happen?

The members of const _CipherType all have a literal type. _CipherType.Login, for example, has a literal type of 1. type CipherType maps over these members, aggregating them into the structural type 1 | 2 | 3 | 4 | 5.

const CipherType asserts its members have type CipherType, which overrides the literal types the compiler inferred for the member in const _CipherType. The compiler sees the type of CipherType.Login as type CipherType (which aliases 1 | 2 | 3 | 4 | 5).

Now consider a mapped type definition:

// `MappedType` is structurally identical to Record<CipherType, boolean>
type MappedType = { [K in CipherType]: boolean };

When the compiler examines instance, it only knows that the type of each of its members is CipherType. That is, the type of instance to the compiler is { [K in 1 | 2 | 3 | 4 | 5]?: boolean }. This doesn't sufficiently overlap with MappedType, which is looking for { [1]: boolean, [2]: boolean, [3]: boolean, [4]: boolean, [5]: boolean }. The failure occurs, because the inferred type can have fewer fields than MappedType.

Workarounds

Option A: Assert the type is correct. You need to manually verify this. The compiler cannot typecheck it.

const instance: MappedType = {
[CipherType.Login]: true,
// ...
} as MappedType;

Option B: Define the mapped type as a partial. Then, inspect its properties before using them.

type MappedType = { [K in CipherType]?: boolean };
const instance: MappedType = {
[CipherType.Login]: true,
// ...
};

if (CipherType.Login in instance) {
// work with `instance[CipherType.Login]`
}

Option C: Use a collection. Consider this approach when downstream code reflects over the result with in or using methods like Object.keys.

const collection = new Map([[CipherType.Login, true]]);

const instance = collection.get(CipherType.Login);
if (instance) {
// work with `instance`
}

const available = [CipherType.Login, CipherType.Card];
if (available.includes(CipherType.Login)) {
// ...
}