Skip to content

Make enums more intuitive with immutable `Set` and `Map` interfaces!

License

Notifications You must be signed in to change notification settings

shaungrady/enum-utils

Repository files navigation

Enum Utils

Make enums more intuitive to use with familiar Set and Map interfaces! Works with numeric, string, and heterogeneous enums, and allows for easy enum type guarding, creation of enum subsets, and safe mapping of enums to anything else—even other enums!

Package: GitHub, npm  |  Releases: Changelog  |  Author: Shaun Grady

Install

npm install @sg.js/enum-utils

Requires TypeScript >=4.7

What's Inside

Classes

EnumSet: Use an enum as an immutable Set.

  • Construction via fromEnum(Enum) method takes a single enum argument.
  • has(value) is a type guard for narrowing serialized values types to the enum.
  • subset(Enum[]) safely creates an EnumSet from a subset of enum members.
  • toEnumMap(mapping) creates an EnumMap, safely mapping an enum (or enum subset) to anything.
  • Shares Set iterable methods.

EnumMap: Use an enum as an immutable Map.

  • Construction via fromEnum(Enum, { [Enum]: any }) provides exhaustive type safety for map keys while preventing numeric enum keys from being coerced to strings.
  • has(value) is a type guard for narrowing serialized values types to the enum.
  • get() has dynamically-typed return value; has() guarded values always return a value and illegal enum values always return undefined.
  • Shares Map iterable methods.

Functions

  • enumToSet: Convert an enum object to Set of enum members.
  • isEnumMember: Type guard for a given enum.
  • isValidEnumMember: Type guard for values that are strings and finite numbers.

Types

  • EnumSetMembers<EnumSet>: Returns either the original enum, or a union type of enum members for enum subsets.
  • EnumMapMembers<EnumMap>: Returns either the original enum, or a union type of enum members for enum subsets.
  • EnumMapValues<EnumMap>: Returns a union type of the EnumMap's values; works best when the map object includes a const assertion.
  • EnumMember: A finite number or string.

Table of Contents

Usage Example

enum Priority {
  Low = 'L',
  Medium = 'M',
  High = 'H',
  ThisIsFine = 'OhNo',
}

// Define our base EnumSet with all Priority members
const priorities = EnumSet.fromEnum(Priority);
// EnumSets are immutable, so aliasing to another variable is safe.
const adminPriorities = priorities;

// Non-admins will only be allowed to use a subset of priorities.
const userPriorities = adminPriorities.subset([
  Priority.Low,
  Priority.Medium,
  Priority.High,
]);

// Create a map with values constrained to a union of i18n keys.
priorityI18nMap = priorities.toEnumMap<I18nKey>({
  [Priority.Low]: 'common.low',
  [Priority.Medium]: 'common.medium',
  [Priority.High]: 'common.high',
  [Priority.ThisIsFine]: 'common.makeItStop',
});
// EnumMaps can also be constructed like this:
//   EnumMap.fromEnum(Priority, { … } as const)
// However, value types aren't as easily constrained with a single type argument,
// so using the `as const` assertion for the mapping is recommended
// for maximum type safety.

const PrioritySelect = () => {
  const { t } = useTranslation();

  // Determine which Priority to set based on user's role
  const { isAdmin } = useSession();
  const allowedPriorities = isAdmin ? adminPriorities : userPriorities;

  // This component allows a `priorityLock` search param to be set that disables
  // the priority select with the given priority. Very secure. 👌
  const { query } = useRouter();
  const priorityLock: unknown = query.priorityLock;
  const hasPriorityLock = priorityLock != null;

  // Guard `priorityLock` to our Priority enum type
  if (hasPriorityLock && !allowedPriorities.has(priorityLock)) {
    const priorityList = [...allowedPriorities].join(', ');
    throw new Error(
      `searchParam 'lockPriority' must be one of: ${priorityList}.`,
    );
  }

  const [priority, setPriority] = useState<Priority>(
    priorityLock ?? allowedPriorities.values().next().value,
  );

  return (
    <Select
      onchange={setPriority}
      disabled={hasPriorityLock}
      optionValues={Array.from(allowedPriorities)}
      renderOption={(priority: Priority) => t(priorityI18nMap.get(priority))}
    />
  );
};

EnumSet

Construction

EnumSet()

Although new EnumSet() can be called with the same value signature of Set, the type arguments aren't very developer-friendly; instead, it's recommended to make use of the EnumSet.fromEnum() static method.


Static methods

EnumSet.fromEnum()

Creates a new EnumSet instance from the given enum object.

fromEnum(Enum);
enum Color {
  Red,
  Green,
  Blue,
}

const colors = EnumSet.fromEnum(Color);

Instance methods

EnumSet.prototype.has()

The has() method returns a boolean indicating whether an enum member with the specified value exists in the EnumSet object or not, acting as a type guard.

has(value);
const colors = EnumSet.fromEnum(Color);
const value: unknown = router.query.color;
let color: Color;

if (colorToHexMap.has(value)) {
  color = value;
}

EnumSet.prototype.subset()

The subset() method returns a new EnumSet instance containing only the enum members specified, which must be members of the EnumSet.

subset([Enum]);
enum Locale {
  enUS = 'en-US',
  enGB = 'en-GB',
  frCA = 'fr-CA',
  esMX = 'es-MX',
  jaJP = 'ja-JP',
}

const siteLocales = EnumSet.from(Locale);

const videoLocales = siteLocales.subset([
  Locale.enUS,
  Locale.enGB,
  Locale.esMX,
]);

if (videoLocales.has(value)) {
  // typeof value ⮕ `Locale.enUS | Locale.enGB | Locale.esMX`
}

EnumSet.prototype.toEnumMap()

Returns an EnumMap instance that maps each enum member in the EnumSet to a corresponding value in the given mappings object. The mapping object value types may either be inferred or defined by the optional type argument. If inferred, it's recommended to use the as const assertion on the mapping object to narrow the value types.

toEnumMap(mapping);

Given the following locales EnumSet instance…

enum Locale {
  enUS = 'en-US',
  enGB = 'en-GB',
  frCA = 'fr-CA',
}

const locales = EnumSet.from(Locale);

An EnumMap can be created with string type values thusly:

const localeFileSuffixes = locales.toEnumMap({
  [Locale.enUS]: 'en',
  [Locale.enGB]: 'en',
  [Locale.frCA]: 'fr-ca',
});

const value: string = localeFileSuffixes.get(Locale.enUS);

However, there are a few ways to increase the type safety of your map values. We can define the map's value type by passing it as the first type argument of the method:

const localeI18nKeys = locales.toEnumMap<I18nKeys>({
  [Locale.enUS]: 'common.americanEnglish',
  [Locale.enGB]: 'common.britishEnglish',
  [Locale.frCA]: 'common.canadianFrench',
});

const i18nValue: I18nKeys = localeFileSuffixes.get(Locale.enUS);

The value type for the map can be narrowed further with the as const assertion:

const localeI18nKeys = locales.toEnumMap({
  [Locale.enUS]: 'common.americanEnglish',
  [Locale.enGB]: 'common.britishEnglish',
  [Locale.frCA]: 'common.canadianFrench',
} as const);

const localeValues:
  | 'common.americanEnglish'
  | 'common.britishEnglish'
  | 'common.canadianFrench' = localeFileSuffixes.get(Locale.enUS);

However, the above example isn't protecting against strings that don't exist in our I18nKeys union type. For the greatest type safety, the as const assertion can be paired with the satisfies operator:

const localeI18nKeys = locales.toEnumMap({
  [Locale.enUS]: 'common.americanEnglish',
  [Locale.enGB]: 'common.britishEnglish',
  // @ts-expect-error
  [Locale.frCA]: 'foo.bar',
} as const satisfies Record<Locale, I18nKeys>);

This approach combines the type narrowing of as const with the type checking of satisfies. It ensures that all keys of the Locale enum are present and that all values are valid I18nKeys. This provides the strongest type safety, catching errors at compile time.


EnumSet.prototype.size

EnumSet.prototype.keys()

EnumSet.prototype.values()

EnumSet.prototype.entries()

EnumSet.prototype.forEach()

These methods behave identically to the Set class. See MDN documentation for more details.


EnumMap

Construction

EnumMap()

Although new EnumMap() can be called with the same value signature of Map, the type arguments aren't very developer-friendly; instead, it's recommended to make use of the EnumMap.fromEnum() static method, or the EnumSet.prototype.toEnumMap() instance method, which allows for typing the mapping keys more easily.


Static methods

EnumMap.fromEnum()

Returns an EnumMap instance that maps each enum member in the given enum to a corresponding value in the given mappings object. The mapping object value types may either be inferred or defined by the optional type argument. If inferred, it's recommended to use the as const assertion on the mapping object to narrow the value types.

See EnumSet.prototype.toEnumMap() for nuances related to type narrowing and safety with the mapping object.

fromEnum(Enum, mapping as const);
enum Color {
  Red,
  Green,
  Blue,
}

const colorHexMap = EnumMap.fromEnum(Color, {
  [Color.Red]: '#f00',
  [Color.Green]: '#0f0',
  [Color.Blue]: '#00f',
} as const);

Instance methods

EnumMap.prototype.has()

The has() method returns a boolean indicating whether an enum member with the specified value exists in the EnumMap object or not, acting as a type guard.

has(value);
const colorToHexMap = EnumMap.fromEnum(Color, {
  [Color.Red]: '#f00',
  [Color.Green]: '#0f0',
  [Color.Blue]: '#00f',
} as const);

const value: unknown = router.query.color;
let color: Color;

if (colorToHexMap.has(value)) {
  color = value;
}

EnumMap.prototype.get()

The get() method returns a specified element from an EnumMap object. If the key's value has been guarded by the has() method, then the return type will be non-nullish. If the key is an illegal enum member type, then return type will be undefined.

get(value);
const colorToHexMap = EnumMap.fromEnum(Color, {
  [Color.Red]: '#f00',
  [Color.Green]: '#0f0',
  [Color.Blue]: '#00f',
} as const);

const value: unknown = router.query.color;
let colorHex: '#f00' | '#0f0' | '#00f';

if (colorToHexMap.has(value)) {
  colorHex = colorToHexMap.get(value);
}

EnumMap.prototype.size

EnumMap.prototype.keys()

EnumMap.prototype.values()

EnumMap.prototype.entries()

EnumMap.prototype.forEach()

These methods behave identically to the Map class. See MDN documentation for more details.

enumToSet()

Converts an enum runtime object to an array of its members. This is safe to use with numeric, string, and heterogeneous enums.

enum Fruit {
  Apple = 'apple',
  Banana = 'banana',
  Orange = 'orange',
}

const fruitSet: EnumSet<Fruit> = enumToSet<Fruit>(Fruit);

console.log(fruitSet); // ⮕ Set { 'apple', 'banana', 'orange' }

isEnumMember()

Checks if the given value is a valid member of the specified enum object, acting as a type guard.

enum CatBreed {
  Siamese,
  NorwegianForestCat,
  DomesticShorthair,
}

if (isEnumMember(CatBreed, 'Greyhound')) {
  // …
}

isValidEnumMember()

Type guards values as an eligible enum member: a finite number or string.

isValidEnumMember('foo'); // ⮕ true
isValidEnumMember(42); // ⮕ true

isValidEnumMember(NaN); // ⮕ false
isValidEnumMember({}); // ⮕ false