Skip to content

accept enum-like proposal with edits mentioned during architecture review #624

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 12 commits into from
Jun 24, 2025
Merged
134 changes: 21 additions & 113 deletions docs/architecture/adr/0025-ts-deprecate-enums.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
adr: "0025"
status: Proposed
status: Accepted
date: 2025-05-30
tags: [clients, typescript]
---
Expand Down Expand Up @@ -35,43 +35,34 @@ In most cases, enums are unnecessary. A readonly (`as const`) object coupled wit
avoids both code generation and type inconsistencies.

```ts
// declare the raw data and reduce repetition with an internal type
const _CipherType = {
const CipherType = Object.freeze({
Login: 1,
SecureNote: 2,
Card: 3,
Identity: 4,
SshKey: 5,
} as const;
} 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);
export type CipherType = _CipherType[keyof typeof 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:

```ts
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>`
```
an enum.

:::warning

- Types that use enums like [computed property names][computed-property-names] issue a compiler
error with this pattern. [This issue is fixed as of TypeScript 5.8][no-member-fields-fixed].
- Certain objects are more difficult to create with this pattern. This is explored in
[Appendix A](#appendix-a-mapped-types-and-enum-likes).
Unlike an enum, TypeScript lifts the type of the members of `const CipherType` to `number`. Code
like the following requires you explicitly type your variables:

```ts
// โœ… Do: strongly type enum-likes
const subject = new Subject<CipherType>();
let value: CipherType = CipherType.Login;

// โŒ Do not: use type inference
const array = [CipherType.Login]; // infers `number[]`
let value = CipherType.Login; // infers `1`
```

:::

Expand All @@ -82,7 +73,7 @@ const m = new Map([[CipherType.Login, ""]]); // `m` is a `Map<CipherType, string
- **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
new enum and replace all enums in the codebase with TypeScript objects. Prohibit disabling of the
lint.

## Decision Outcome
Expand All @@ -92,6 +83,8 @@ Chosen option: **Deprecate enum use**
### Positive Consequences

- Allows for cases where autogenerated code introduces an enum by necessity.
- Literals (e.g. `1`) convert to the enum-like type with full type safety.
- Works with mapped types such as `Record<T, U>` and discriminated unions.
- 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.
Expand All @@ -101,98 +94,13 @@ Chosen option: **Deprecate enum use**

- 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%.
- Enum-likes cannot be referenced by angular templates

### 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:

```ts
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][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:

```ts
// `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.

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

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

```ts
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`.

```ts
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)) {
// ...
}
```

[computed-property-names]:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#computed_property_names
[literal-type]: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types
[no-enum-lint]: https://github.com/bitwarden/clients/blob/main/libs/eslint/platform/no-enums.mjs
[no-enum-configuration]:
https://github.com/bitwarden/clients/blob/032fedf308ec251f17632d7d08c4daf6f41a4b1d/eslint.config.mjs#L77
[no-member-fields-fixed]:
https://devblogs.microsoft.com/typescript/announcing-typescript-5-8-beta/#preserved-computed-property-names-in-declaration-files
53 changes: 53 additions & 0 deletions docs/contributing/code-style/angular.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,56 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"

export class DifferentPackageService {}
```

## Enum-likes ([ADR-0025](../../architecture/adr/0025-ts-deprecate-enums.md))

For general guidance on enum-likes, consult [Avoid TypeScript Enums](./enums.md).

### String-backed Enum-likes

String-typed enum likes can be used as inputs of a component directly. Simply expose the enum-like
property from your component:

```ts
// given:
const EnumLike = { Some = "some", Value: "value" };
type EnumLike = EnumLike[keyof typeof EnumLike];

// add the input:
@Component({ ... })
class YourComponent {
@Input() input: EnumLike = EnumLike.Some;

// ...
}
```

Composers can use the enum's string values directly:

```html
<my-component input="value" />
```

### Numeric Enum-likes

Using numeric enum-likes in components should be avoided. If it is necessary, follow the same
pattern as a string-backed enum.

Composers that need hard-coded enum-likes in their template should expose the data from their
component:

```ts
import { EnumLike } from "...";

// add the input to your component:
@Component({ ... })
class TheirComponent {
protected readonly EnumLike = EnumLike;
}
```

And then bind the input in the template:

```ts
<my-component [input]='EnumLike.Value' />
```
88 changes: 71 additions & 17 deletions docs/contributing/code-style/enums.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,15 @@ use [constant objects][constant-object-pattern] instead of introducing a new enu

- Use the same name for your type- and value-declaration.
- Use `type` to derive type information from the const object.
- Avoid asserting the type of an enum-like. Use explicit types instead.
- 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
### Numeric enum-likes

Given the following enum:

```ts
export enum CipherType = {
export enum CipherType {
Login: 1,
SecureNote: 2,
Card: 3,
Expand All @@ -33,19 +27,17 @@ export enum CipherType = {
You can redefine it as an object like so:

```ts
const _CipherType = {
// freeze to prevent member injection
export const CipherType = Object.freeze({
Login: 1,
SecureNote: 2,
Card: 3,
Identity: 4,
SshKey: 5,
} as const;
} as const);

type _CipherType = typeof _CipherType;

export type CipherType = _CipherType[keyof _CipherType];
export const CipherType: Readonly<{ [K in keyof typeof _CipherType]: CipherType }> =
Object.freeze(_CipherType);
// derive the enum-like type from the raw data
export type CipherType = CipherType[keyof typeof CipherType];
```

And use it like so:
Expand All @@ -59,9 +51,71 @@ function doSomething(type: CipherType) {}

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

// advanced use-case: discriminated union definition
type CipherContent =
| { type: typeof CipherType.Login, username: EncString, ... }
| { type: typeof CipherType.SecureNote, note: EncString, ... }
```

:::warning

Unlike an enum, TypeScript lifts the type of the members of `const CipherType` to `number`. Code
like the following requires you explicitly type your variables:

```ts
// โœ… Do: strongly type enum-likes
let value: CipherType = CipherType.Login;
const array: CipherType[] = [CipherType.Login];
const subject = new Subject<CipherType>();

// โŒ Do not: use type inference
let value = CipherType.Login; // infers `1`
const array = [CipherType.Login]; // infers `number[]`

// โŒ Do not: use type assertions
let value = CipherType.Login as CipherType; // this operation is unsafe
```

:::

### String enum-likes

The above pattern also works with string-typed enum members:

```ts
// freeze to prevent member injection
export const CredentialType = Object.freeze({
Password: "password",
Username: "username",
Email: "email",
SshKey: "ssh-key",
} as const);

// derive the enum-like type from the raw data
export type CredentialType = CredentialType[keyof typeof CredentialType];
```

The following utilities may assist introspection:
:::note[Enum-likes are structural types!]

Unlike string-typed enums, enum-likes do not reify a type for each member. This means that you can
use their string value or their enum member interchangeably.

```ts
let value: CredentialType = CredentialType.Username;

// this is typesafe!
value = "email";
```

However, the string-typed values are not always identified as enum members. Thus, when the const
object is in scope, prefer it to the literal value.

:::

## Utilities

The following utilities can be used to maintain type safety at runtime.

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