diff --git a/docs/architecture/adr/0025-ts-deprecate-enums.md b/docs/architecture/adr/0025-ts-deprecate-enums.md index 25bf34d2f..d37bc912f 100644 --- a/docs/architecture/adr/0025-ts-deprecate-enums.md +++ b/docs/architecture/adr/0025-ts-deprecate-enums.md @@ -1,6 +1,6 @@ --- adr: "0025" -status: Proposed +status: Accepted date: 2025-05-30 tags: [clients, typescript] --- @@ -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` -const a = [CipherType.Login, CipherType.Card]; // `a` is an `Array` -const m = new Map([[CipherType.Login, ""]]); // `m` is a `Map` -``` +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(); +let value: CipherType = CipherType.Login; + +// ❌ Do not: use type inference +const array = [CipherType.Login]; // infers `number[]` +let value = CipherType.Login; // infers `1` +``` ::: @@ -82,7 +73,7 @@ const m = new Map([[CipherType.Login, ""]]); // `m` is a `Map` 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. @@ -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.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 -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 diff --git a/docs/contributing/code-style/angular.md b/docs/contributing/code-style/angular.md index 2a26a8cfa..0f7322978 100644 --- a/docs/contributing/code-style/angular.md +++ b/docs/contributing/code-style/angular.md @@ -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 + +``` + +### 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 + +``` diff --git a/docs/contributing/code-style/enums.md b/docs/contributing/code-style/enums.md index 4d972ce0c..405a51bf5 100644 --- a/docs/contributing/code-style/enums.md +++ b/docs/contributing/code-style/enums.md @@ -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, @@ -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: @@ -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(); + +// ❌ 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";