Skip to content
Merged
125 changes: 16 additions & 109 deletions docs/architecture/adr/0025-ts-deprecate-enums.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,42 +36,35 @@ 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;

type _CipherType = typeof _CipherType;
} as const);

// 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 Down Expand Up @@ -107,92 +100,6 @@ Chosen option: **Deprecate enum use**
- 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
25 changes: 19 additions & 6 deletions docs/contributing/code-style/enums.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,15 @@ export enum CipherType = {
You can redefine it as an object like so:

```ts
const _CipherType = {
const CipherType = {
Login: 1,
SecureNote: 2,
Card: 3,
Identity: 4,
SshKey: 5,
} 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);
export type CipherType = CipherType[keyof typeof CipherType];
```

And use it like so:
Expand All @@ -61,6 +57,23 @@ function doSomething(type: CipherType) {}
doSomething(CipherType.Card);
```

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

:::

The following utilities may assist introspection:

```ts
Expand Down