Skip to content

Commit 3556912

Browse files
authored
accept enum-like proposal with edits mentioned during architecture review (#624)
* remove strongly typed enum-likes; they break discriminated unions * switch status to active * add string-based enum examples * add angular guidance from architecture review
1 parent 9a00afe commit 3556912

File tree

3 files changed

+145
-130
lines changed

3 files changed

+145
-130
lines changed
Lines changed: 21 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
adr: "0025"
3-
status: Proposed
3+
status: Accepted
44
date: 2025-05-30
55
tags: [clients, typescript]
66
---
@@ -35,43 +35,34 @@ In most cases, enums are unnecessary. A readonly (`as const`) object coupled wit
3535
avoids both code generation and type inconsistencies.
3636

3737
```ts
38-
// declare the raw data and reduce repetition with an internal type
39-
const _CipherType = {
38+
const CipherType = Object.freeze({
4039
Login: 1,
4140
SecureNote: 2,
4241
Card: 3,
4342
Identity: 4,
4443
SshKey: 5,
45-
} as const;
44+
} as const);
4645

47-
type _CipherType = typeof _CipherType;
48-
49-
// derive the enum-like type from the raw data
50-
export type CipherType = _CipherType[keyof _CipherType];
51-
52-
// assert that the raw data is of the enum-like type
53-
export const CipherType: Readonly<{ [K in keyof _CipherType]: CipherType }> =
54-
Object.freeze(_CipherType);
46+
export type CipherType = _CipherType[keyof typeof CipherType];
5547
```
5648

5749
This code creates a `type CipherType` that allows arguments and variables to be typed similarly to
58-
an enum. It also strongly types the `const CiperType` so that direct accesses of its members
59-
preserve type safety. This ensures that type inference properly limits the accepted values to those
60-
allowed by `type CipherType`. Without the type assertion, the compiler infers `number` in these
61-
cases:
62-
63-
```ts
64-
const s = new Subject(CipherType.Login); // `s` is a `Subject<CipherType>`
65-
const a = [CipherType.Login, CipherType.Card]; // `a` is an `Array<CipherType>`
66-
const m = new Map([[CipherType.Login, ""]]); // `m` is a `Map<CipherType, string>`
67-
```
50+
an enum.
6851

6952
:::warning
7053

71-
- Types that use enums like [computed property names][computed-property-names] issue a compiler
72-
error with this pattern. [This issue is fixed as of TypeScript 5.8][no-member-fields-fixed].
73-
- Certain objects are more difficult to create with this pattern. This is explored in
74-
[Appendix A](#appendix-a-mapped-types-and-enum-likes).
54+
Unlike an enum, TypeScript lifts the type of the members of `const CipherType` to `number`. Code
55+
like the following requires you explicitly type your variables:
56+
57+
```ts
58+
// ✅ Do: strongly type enum-likes
59+
const subject = new Subject<CipherType>();
60+
let value: CipherType = CipherType.Login;
61+
62+
// ❌ Do not: use type inference
63+
const array = [CipherType.Login]; // infers `number[]`
64+
let value = CipherType.Login; // infers `1`
65+
```
7566

7667
:::
7768

@@ -82,7 +73,7 @@ const m = new Map([[CipherType.Login, ""]]); // `m` is a `Map<CipherType, string
8273
- **Deprecate enum use** - Allow enums to exist for historic or technical purposes, but prohibit the
8374
introduction of new ones. Reduce the lint to a "warning" and allow the lint to be disabled.
8475
- **Eliminate enum use** - This is the current state of affairs. Prohibit the introduction of any
85-
new enum and replace all enums in the codebase with typescript objects. Prohibit disabling of the
76+
new enum and replace all enums in the codebase with TypeScript objects. Prohibit disabling of the
8677
lint.
8778

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

9485
- Allows for cases where autogenerated code introduces an enum by necessity.
86+
- Literals (e.g. `1`) convert to the enum-like type with full type safety.
87+
- Works with mapped types such as `Record<T, U>` and discriminated unions.
9588
- Developers receive a warning in their IDE to discourage new enums.
9689
- The warning can direct them to our contributing docs, where they can learn typesafe alternatives.
9790
- Our compiled code size decreases when enums are replaced.
@@ -101,98 +94,13 @@ Chosen option: **Deprecate enum use**
10194

10295
- Unnecessary usage may persist indefinitely on teams carrying a high tech debt.
10396
- The lint increased the number of FIXME comments in the code by about 10%.
97+
- Enum-likes cannot be referenced by angular templates
10498

10599
### Plan
106100

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

110-
## Appendix A: Mapped Types and Enum-likes
111-
112-
Mapped types cannot determine that a mapped enum-like object is fully assigned. Code like the
113-
following causes a compiler error:
114-
115-
```ts
116-
const instance: Record<CipherType, boolean> = {
117-
[CipherType.Login]: true,
118-
[CipherType.SecureNote]: false,
119-
[CipherType.Card]: true,
120-
[CipherType.Identity]: true,
121-
[CipherType.SshKey]: true,
122-
};
123-
```
124-
125-
#### Why does this happen?
126-
127-
The members of `const _CipherType` all have a [literal type][literal-type]. `_CipherType.Login`, for
128-
example, has a literal type of `1`. `type CipherType` maps over these members, aggregating them into
129-
the structural type `1 | 2 | 3 | 4 | 5`.
130-
131-
`const CipherType` asserts its members have `type CipherType`, which overrides the literal types the
132-
compiler inferred for the member in `const _CipherType`. The compiler sees the type of
133-
`CipherType.Login` as `type CipherType` (which aliases `1 | 2 | 3 | 4 | 5`).
134-
135-
Now consider a mapped type definition:
136-
137-
```ts
138-
// `MappedType` is structurally identical to Record<CipherType, boolean>
139-
type MappedType = { [K in CipherType]: boolean };
140-
```
141-
142-
When the compiler examines `instance`, it only knows that the type of each of its members is
143-
`CipherType`. That is, the type of `instance` to the compiler is
144-
`{ [K in 1 | 2 | 3 | 4 | 5]?: boolean }`. This doesn't sufficiently overlap with `MappedType`, which
145-
is looking for `{ [1]: boolean, [2]: boolean, [3]: boolean, [4]: boolean, [5]: boolean }`. The
146-
failure occurs, because the inferred type can have fewer fields than `MappedType`.
147-
148-
### Workarounds
149-
150-
**Option A: Assert the type is correct.** You need to manually verify this. The compiler cannot
151-
typecheck it.
152-
153-
```ts
154-
const instance: MappedType = {
155-
[CipherType.Login]: true,
156-
// ...
157-
} as MappedType;
158-
```
159-
160-
**Option B: Define the mapped type as a partial.** Then, inspect its properties before using them.
161-
162-
```ts
163-
type MappedType = { [K in CipherType]?: boolean };
164-
const instance: MappedType = {
165-
[CipherType.Login]: true,
166-
// ...
167-
};
168-
169-
if (CipherType.Login in instance) {
170-
// work with `instance[CipherType.Login]`
171-
}
172-
```
173-
174-
**Option C: Use a collection.** Consider this approach when downstream code reflects over the result
175-
with `in` or using methods like `Object.keys`.
176-
177-
```ts
178-
const collection = new Map([[CipherType.Login, true]]);
179-
180-
const instance = collection.get(CipherType.Login);
181-
if (instance) {
182-
// work with `instance`
183-
}
184-
185-
const available = [CipherType.Login, CipherType.Card];
186-
if (available.includes(CipherType.Login)) {
187-
// ...
188-
}
189-
```
190-
191-
[computed-property-names]:
192-
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#computed_property_names
193-
[literal-type]: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types
194104
[no-enum-lint]: https://github.com/bitwarden/clients/blob/main/libs/eslint/platform/no-enums.mjs
195105
[no-enum-configuration]:
196106
https://github.com/bitwarden/clients/blob/032fedf308ec251f17632d7d08c4daf6f41a4b1d/eslint.config.mjs#L77
197-
[no-member-fields-fixed]:
198-
https://devblogs.microsoft.com/typescript/announcing-typescript-5-8-beta/#preserved-computed-property-names-in-declaration-files

docs/contributing/code-style/angular.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,56 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
194194

195195
export class DifferentPackageService {}
196196
```
197+
198+
## Enum-likes ([ADR-0025](../../architecture/adr/0025-ts-deprecate-enums.md))
199+
200+
For general guidance on enum-likes, consult [Avoid TypeScript Enums](./enums.md).
201+
202+
### String-backed Enum-likes
203+
204+
String-typed enum likes can be used as inputs of a component directly. Simply expose the enum-like
205+
property from your component:
206+
207+
```ts
208+
// given:
209+
const EnumLike = { Some = "some", Value: "value" };
210+
type EnumLike = EnumLike[keyof typeof EnumLike];
211+
212+
// add the input:
213+
@Component({ ... })
214+
class YourComponent {
215+
@Input() input: EnumLike = EnumLike.Some;
216+
217+
// ...
218+
}
219+
```
220+
221+
Composers can use the enum's string values directly:
222+
223+
```html
224+
<my-component input="value" />
225+
```
226+
227+
### Numeric Enum-likes
228+
229+
Using numeric enum-likes in components should be avoided. If it is necessary, follow the same
230+
pattern as a string-backed enum.
231+
232+
Composers that need hard-coded enum-likes in their template should expose the data from their
233+
component:
234+
235+
```ts
236+
import { EnumLike } from "...";
237+
238+
// add the input to your component:
239+
@Component({ ... })
240+
class TheirComponent {
241+
protected readonly EnumLike = EnumLike;
242+
}
243+
```
244+
245+
And then bind the input in the template:
246+
247+
```ts
248+
<my-component [input]='EnumLike.Value' />
249+
```

docs/contributing/code-style/enums.md

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,15 @@ use [constant objects][constant-object-pattern] instead of introducing a new enu
77

88
- Use the same name for your type- and value-declaration.
99
- Use `type` to derive type information from the const object.
10+
- Avoid asserting the type of an enum-like. Use explicit types instead.
1011
- Create utilities to convert and identify enums modelled as primitives.
1112

12-
:::tip
13-
14-
This pattern should simplify the usage of your new objects, improve type safety in files that have
15-
adopted TS-strict, and make transitioning an enum to a const object much easier.
16-
17-
:::
18-
19-
### Example
13+
### Numeric enum-likes
2014

2115
Given the following enum:
2216

2317
```ts
24-
export enum CipherType = {
18+
export enum CipherType {
2519
Login: 1,
2620
SecureNote: 2,
2721
Card: 3,
@@ -33,19 +27,17 @@ export enum CipherType = {
3327
You can redefine it as an object like so:
3428

3529
```ts
36-
const _CipherType = {
30+
// freeze to prevent member injection
31+
export const CipherType = Object.freeze({
3732
Login: 1,
3833
SecureNote: 2,
3934
Card: 3,
4035
Identity: 4,
4136
SshKey: 5,
42-
} as const;
37+
} as const);
4338

44-
type _CipherType = typeof _CipherType;
45-
46-
export type CipherType = _CipherType[keyof _CipherType];
47-
export const CipherType: Readonly<{ [K in keyof typeof _CipherType]: CipherType }> =
48-
Object.freeze(_CipherType);
39+
// derive the enum-like type from the raw data
40+
export type CipherType = CipherType[keyof typeof CipherType];
4941
```
5042

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

6052
// And used as a value (just like a regular `enum`)
6153
doSomething(CipherType.Card);
54+
55+
// advanced use-case: discriminated union definition
56+
type CipherContent =
57+
| { type: typeof CipherType.Login, username: EncString, ... }
58+
| { type: typeof CipherType.SecureNote, note: EncString, ... }
59+
```
60+
61+
:::warning
62+
63+
Unlike an enum, TypeScript lifts the type of the members of `const CipherType` to `number`. Code
64+
like the following requires you explicitly type your variables:
65+
66+
```ts
67+
// ✅ Do: strongly type enum-likes
68+
let value: CipherType = CipherType.Login;
69+
const array: CipherType[] = [CipherType.Login];
70+
const subject = new Subject<CipherType>();
71+
72+
// ❌ Do not: use type inference
73+
let value = CipherType.Login; // infers `1`
74+
const array = [CipherType.Login]; // infers `number[]`
75+
76+
// ❌ Do not: use type assertions
77+
let value = CipherType.Login as CipherType; // this operation is unsafe
78+
```
79+
80+
:::
81+
82+
### String enum-likes
83+
84+
The above pattern also works with string-typed enum members:
85+
86+
```ts
87+
// freeze to prevent member injection
88+
export const CredentialType = Object.freeze({
89+
Password: "password",
90+
Username: "username",
91+
Email: "email",
92+
SshKey: "ssh-key",
93+
} as const);
94+
95+
// derive the enum-like type from the raw data
96+
export type CredentialType = CredentialType[keyof typeof CredentialType];
6297
```
6398

64-
The following utilities may assist introspection:
99+
:::note[Enum-likes are structural types!]
100+
101+
Unlike string-typed enums, enum-likes do not reify a type for each member. This means that you can
102+
use their string value or their enum member interchangeably.
103+
104+
```ts
105+
let value: CredentialType = CredentialType.Username;
106+
107+
// this is typesafe!
108+
value = "email";
109+
```
110+
111+
However, the string-typed values are not always identified as enum members. Thus, when the const
112+
object is in scope, prefer it to the literal value.
113+
114+
:::
115+
116+
## Utilities
117+
118+
The following utilities can be used to maintain type safety at runtime.
65119

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

0 commit comments

Comments
 (0)