Skip to content

Introduce ADR-0025: deprecate typescript enums #605

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jun 4, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Yellowpages
Yubico
YubiKey
YubiKeys
typesafe

# Forbidden words
!auto-fill
Expand Down
90 changes: 90 additions & 0 deletions docs/architecture/adr/0025-ts-deprecate-enums.md
Copy link
Member Author

@audreyality audreyality May 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ“ Our discussions to date have indicated that our Rust code can still generate enums, so this document assumes that we can deprecate but not eliminate enums.

๐Ÿ’ญ I'm not sure if we should include direction for the SDK in this. Per @dani-garcia rust-bindgen produces typescript enums and we can override this with tsify. The "deprecate" option includes an exception made for this use-case. If we want to deprecate these uses too, then I think we'd want to go with the "eliminate" option and expand the scope of the ADR to include the rust/SDK changes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I went to look a bit more into what wasm-bindgen is doing internally when not using tsify, and this is what it generates:

In *.d.ts file:

export enum LogLevel {
  Trace = 0,
   ... all other variants
}

In the *.js file:

/**
 * @enum {0 | 1 | 2 | 3 | 4}
 */
export const LogLevel = Object.freeze({
  Trace: 0,
  0: "Trace",
  ... all other variants
});

Not sure why they have that mapping from the number back to the string value, but other than that this seems fairly similar to what we want (but without the type alias).

I still think we should be encouraging using full discriminated unions with tsify, though.

Copy link
Member Author

@audreyality audreyality Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why they have that mapping from the number back to the string value, but other than that this seems fairly similar to what we want (but without the type alias).

It's maintaining compatibility with TypeScript. TS enums support bidirectional mapping. You can use the symbol name to get the value or the value to get the symbol name.

The pattern that @Patrick-Pimentel-Bitwarden, @coroiu, and I have been working on moves the name mapping into a utility function so that we can provide proper error handling. It's been extracted from this PR and moved to #607, since the ADR has to go through the architecture council.

Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
adr: "0025"
status: Proposed
date: 2025-05-30
tags: [clients, typescript]
---

# 0025 - Deprecate TypeScript Enum Types

<AdrTable frontMatter={frontMatter}></AdrTable>

## 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. Reducing or eliminating

### Detection

Typescript deprecation can be linted using a fairly short ESlint plugin. The code has [already been
contributed to main][no-enum-lint] as a suggestion. The same PR adds `FIXME` comments for each team
to address.

### Replacement code

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

```ts
export const CipherType = {
Login: 1,
SecureNote: 2,
Card: 3,
Identity: 4,
SshKey: 5,
} as const;

export type CipherType = (typeof CipherType)[keyof typeof CipherType];

// Can be imported together
import { CipherType } from "./cipher-type";

// Used as a type
function doSomething(type: CipherType) {}

// And used as a value (just like a regular `enum`)
doSomething(CipherType.Card);
```

## Considered Options

- **Allow enums, but advise against them** - This is the current state of affairs, as implemented by
the aforementioned PR. With this option, teams MUST address the readmes, but MAY address them by
disabling the lint.
- **Deprecate enum use** - Allow enums to exist for historic purposes, but prohibit the introduction
of new ones. Increase the lint to a "warning". This lets legacy code continue running
indefinitely, and would allow for cases where autogenerated code introduces an enum by necessity.
- **Eliminate enum use** - Prohibit the introduction of any new enum and replace all enums in the
codebase with typescript objects. Increase the lint to an "error".

## Decision Outcome

Chosen option: **Deprecate enum use**

### Positive Consequences

- Developers receive a warning in their IDE, which discourages new enums.
- The warning can direct them to our contributing docs, where they can learn typesafe alternatives.
- Over time, our code size decreases as 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 increases 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".
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or are you maybe suggesting a change to our current approach, i.e. to downgrade error -> warning

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 58e5177.


[no-enum-lint]: https://github.com/bitwarden/clients/blob/main/libs/eslint/platform/no-enums.mjs
91 changes: 77 additions & 14 deletions docs/contributing/code-style/enums.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,88 @@
# TypeScript Const Enums
# Avoid TypeScript Enums

We don't use Typescript enums because they are not fully type-safe and can cause surprises. Instead
of using enums, we use constant objects.
TypeScript enums are not fully type-safe and can [cause surprises][enum-surprises]. Your code should
use [constant objects][constant-object-pattern] instead of introducing a new enum.

## Our Recommended Approach
## Our Recommended Approach ([ADR-0025](../../architecture/adr/0025-ts-deprecate-enums.md))

- Use the same name for your type- and value-declaration.
- Use `type` to derive type information from the const object.
- Create utilities to convert and identify enums modelled as primitives.

:::tip

This pattern should simplify the usage of your new objects, improve type safety in files that have
adopted TS-strict, and make transitioning an enum to a const object much easier.

:::

### Example

Given the following enum:

```ts
export const PasskeyActions = {
Register: "register",
Authenticate: "authenticate",
export enum CipherType = {
Login: 1,
SecureNote: 2,
Card: 3,
Identity: 4,
SshKey: 5,
};
```

You can redefine it as an object like so:

```ts
const RawCipherType = {
Login: 1,
SecureNote: 2,
Card: 3,
Identity: 4,
SshKey: 5,
} as const;

export type PasskeyActionValue = (typeof PasskeyActions)[keyof typeof PasskeyActions];
type RawCipherType = typeof RawCipherType;

declare function usePasskeyAction(action: PasskeyActionValue): void;
export type CipherType = RawCipherType[keyof RawCipherType];

usePasskey(PasskeyActions.Register); // โœ… Valid
usePasskey(0); // โŒ Invalid
export const CipherType: Readonly<Record<keyof RawCipherType, CipherType>> =
Object.freeze(RawCipherType);
```

## Resources Used
And use it like so:

```ts
// Can be imported together
import { CipherType } from "./cipher-type";

// Used as a type
function doSomething(type: CipherType) {}

// And used as a value (just like a regular `enum`)
doSomething(CipherType.Card);
```

The following utilities may assist introspection:

```ts
import { CipherType } from "./cipher-type";

const namesByCipherType = new Map<CipherType, keyof CipherType>(
Array.fromEntries(Object.entries(CipherType), ([k, v]) => [v, k]),
);

export function isCipherType(value: number): value is CipherType {
return namesByCipherType.has(value);
}

export function asCipherType(value: number): CipherType | undefined {
return isCipherType(value) ? value : undefined;
}

export function nameOfCipherType(name: CipherType): keyof CipherType | undefined {
return namesByCipherType.get(value);
}
```

- https://dev.to/ivanzm123/dont-use-enums-in-typescript-they-are-very-dangerous-57bh
- https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums
[enum-surprises]: https://dev.to/ivanzm123/dont-use-enums-in-typescript-they-are-very-dangerous-57bh
[constant-object-pattern]: https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums