Skip to content

Commit b412071

Browse files
committed
remove strongly typed enum-likes; they break discriminated unions
1 parent 213d5c5 commit b412071

File tree

2 files changed

+35
-115
lines changed

2 files changed

+35
-115
lines changed

docs/architecture/adr/0025-ts-deprecate-enums.md

Lines changed: 16 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -36,42 +36,35 @@ avoids both code generation and type inconsistencies.
3636

3737
```ts
3838
// declare the raw data and reduce repetition with an internal type
39-
const _CipherType = {
39+
const CipherType = Object.freeze({
4040
Login: 1,
4141
SecureNote: 2,
4242
Card: 3,
4343
Identity: 4,
4444
SshKey: 5,
45-
} as const;
46-
47-
type _CipherType = typeof _CipherType;
45+
} as const);
4846

4947
// 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);
48+
export type CipherType = _CipherType[keyof typeof CipherType];
5549
```
5650

5751
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-
```
52+
an enum.
6853

6954
:::warning
7055

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).
56+
Unlike an enum, typescript lifts the type of the members of `const CipherType` to `number`. Code
57+
like the following requires you explicitly type your variables:
58+
59+
```ts
60+
// ✅ Do: strongly type enum-likes
61+
const subject = new Subject<CipherType>();
62+
let value: CipherType = CipherType.Login;
63+
64+
// ❌ Do not: use type inference
65+
const array = [CipherType.Login]; // infers `number[]`
66+
let value = CipherType.Login; // infers `1`
67+
```
7568

7669
:::
7770

@@ -107,92 +100,6 @@ Chosen option: **Deprecate enum use**
107100
- Update contributing docs with patterns and best practices for enum replacement.
108101
- Update the reporting level of the lint to "warning".
109102

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
194103
[no-enum-lint]: https://github.com/bitwarden/clients/blob/main/libs/eslint/platform/no-enums.mjs
195104
[no-enum-configuration]:
196105
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/enums.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,15 @@ export enum CipherType = {
3333
You can redefine it as an object like so:
3434

3535
```ts
36-
const _CipherType = {
36+
const CipherType = {
3737
Login: 1,
3838
SecureNote: 2,
3939
Card: 3,
4040
Identity: 4,
4141
SshKey: 5,
4242
} as const;
4343

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);
44+
export type CipherType = CipherType[keyof typeof CipherType];
4945
```
5046

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

60+
:::warning
61+
62+
Unlike an enum, typescript lifts the type of the members of `const CipherType` to `number`. Code
63+
like the following requires you explicitly type your variables:
64+
65+
```ts
66+
// ✅ Do: strongly type enum-likes
67+
const subject = new Subject<CipherType>();
68+
let value: CipherType = CipherType.Login;
69+
70+
// ❌ Do not: use type inference
71+
const array = [CipherType.Login]; // infers `number[]`
72+
let value = CipherType.Login; // infers `1`
73+
```
74+
75+
:::
76+
6477
The following utilities may assist introspection:
6578

6679
```ts

0 commit comments

Comments
 (0)