@northscaler/better-enum
Version:
Better enumeration support for TypeScript than its `enum` keyword. This class is modeled after [Java's enumeration pattern](https://docs.oracle.com/javase/tutorial/java/javaOO/enum.html), where enums are instances of classes. This library provides a base
216 lines (192 loc) • 8.07 kB
text/typescript
import isValidJavaScriptIdentifier from './is-valid-javascript-identifier'
import {
DuplicateEnumerationDeclarationError,
IllegallyExtendedEnumerationError,
InvalidEnumerationNameError,
InvalidEnumerationOrdinalError,
UnidentifiableEnumerationValueError,
UnknownEnumerationClassError,
} from './errors'
type EnumerationsByName = { [name: string]: any }
type EnumerationsByOrdinal = { [ordinal: number]: any }
// key type is a class function; that is, given `class Foo {}`, then symbol `Foo`
const enums = new Map<
// eslint-disable-next-line @typescript-eslint/ban-types
Function,
{ byName: EnumerationsByName; byOrdinal: EnumerationsByOrdinal }
>()
/**
* Base enumeration class for the pattern where enumerated values are represented by instances of classes rather than by scalar string or numeric values alone.
*
* Every subclass `T` has the following requirements.
*
* * `T` must not be extended.
*
* * `T` must define a `private` constructor whose first two arguments are `name: string, ordinal: number`,
* * where `name` is a valid JavaScript identifier that is the same as the instance's static identifier on `T`, and
* * where `ordinal` is an integral value that is unique amongst all instances.
*
* * `T` must define a `static` method `of(it: T | string | number): T` that returns an instance of `T` whose `name` or `ordinal` is equal to the given value, or that is identical to the instance of `T` given, and must throw {@link UnidentifiableEnumerationValueError} otherwise.
*
* > NOTE: {@link Enumeration} provides a convenient function that subclasses can delegate to in order to implement the required `of` method above.
* > Use `import { _of } from '@northscaler/better-enum' and delegate the subclass's `of()` method to it.
*
* * `T` must define a `static` method `values(): T[]` that returns all instances of `T`.
* No ordering is prescribed.
* However, you can use one of the convenient sorting functions provided by this class, {@link sortByName} or {@link sortByOrdinal}, to sort the instances, or write your own.
*
* > NOTE: {@link Enumeration} provides a convenient function that subclasses can delegate to in order to implement the required `values` method above.
* > Use `import { _values } from '@northscaler/better-enum' and delegate the subclass's `values()` method to it.
*/
export class Enumeration<T extends Enumeration<T>> {
/**
* Constructs an enumerated value.
* @param _name The symbolic name of this instance.
* It must be a valid JavaScript symbol and must be unique amongst all instances of this class's type.
* @param _ordinal The numeric ordinal of this instance.
* It must be an integer value and must be unique amongst instances of this class's type.
* @param _class The class function of the subclass calling this constructor.
* For example, given `class Bool extends Enumeration<Bool> { ... }`, the reference to the class's function is `Bool`.
* @throws InvalidEnumerationNameError If the `_name` parameter is not a valid JavaScript identifier.
* @throws InvalidEnumerationOrdinalError If the `_ordinal` parameter is not an integer.
* @throws DuplicateEnumerationDeclarationError If there is already a value with the given `_name` or `_ordinal` amongst all instances of this class's type.
* @protected
*/
protected constructor(
protected _name: string,
protected _ordinal: number,
// eslint-disable-next-line @typescript-eslint/ban-types
protected _class: Function
) {
if (
Object.getPrototypeOf(Object.getPrototypeOf(this)).constructor.name !==
Enumeration.name
) {
throw new IllegallyExtendedEnumerationError({ context: { this_: this } })
}
if (!isValidJavaScriptIdentifier(_name)) {
throw new InvalidEnumerationNameError({ context: { name: _name } })
}
const n = parseInt(String(_ordinal))
if (isNaN(n) || n !== _ordinal) {
throw new InvalidEnumerationOrdinalError({
context: { ordinal: _ordinal },
})
}
let class_ = enums.get(this._class)
if (!class_) {
enums.set(this._class, (class_ = { byName: {}, byOrdinal: {} }))
}
if (class_!.byName[_name] || class_!.byOrdinal[_ordinal]) {
throw new DuplicateEnumerationDeclarationError({
context: { name: _name, ordinal: _ordinal },
})
}
class_!.byName[_name] = class_!.byOrdinal[_ordinal] = (this as unknown) as T
}
/**
* The symbolic name of this enumerated value.
*/
name() {
return this._name
}
/**
* The integer ordinal of this enumerated value.
*/
ordinal() {
return this._ordinal
}
/**
* Returns whether the given object has the same name, ordinal & constructor name as this object.
*/
equals(that: T) {
return (
this.constructor.name === that.constructor.name &&
this.name() === that.name() &&
this.ordinal() === that.ordinal()
)
}
/**
* Returns the symbolic name of this enumerated value.
* @param fullyQualified Whether to return the return value of {@link toFullyQualifiedString} or just the symbolic name; defaults to `false`.
*/
toString(fullyQualified = false) {
return fullyQualified ? this.toFullyQualifiedString() : this._name
}
/**
* Returns a colon-separated string of this class's name, its symbolic name, and its ordinal (like `Bool:TRUE:1`).
*/
toFullyQualifiedString() {
return `${this.constructor.name}:${this._name}:${this._ordinal}`
}
/**
* Default `toJSON()` protocol method.
* Returns the symbolic name of this enumerated value.
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#tojson_behavior
*
* To implement a JSON [`reviver`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#using_the_reviver_parameter), use the static `of` method defined on your class.
*/
toJSON() {
return this.name()
}
}
/**
* A function that attempts to locate an enumerated value given its name or ordinal.
* If given an argument that is `typeof object`, it must be an enumerated value, which is simply returned.
* Any value that is not among the symbolic names, ordinals, or instances of the enumeration class will cause this method to throw an `UnidentifiableEnumerationValueError`.
*
* This function is only intended to be used by subclasses of {@link Enumeration}.
*
* @param it The value being used to identify the enumerated value.
* @param clazz A reference to the enumeration class (if `class Foo ... {}`, then the reference `Foo` should be given).
*/
export function _of<T extends Enumeration<T>>(
it: T | string | number,
// eslint-disable-next-line @typescript-eslint/ban-types
clazz: Function
): T {
let e: T
const type = typeof it
const f = enums.get(clazz)
if (!f) {
throw new UnknownEnumerationClassError({ context: { clazz } })
}
if (type === 'string') {
e = f!.byName[it as string]
} else if (type === 'number') {
e = f!.byOrdinal[it as number]
} else if (it instanceof clazz) {
e = it as T
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (e) {
return e
}
throw new UnidentifiableEnumerationValueError({
message: 'unidentifiable enumeration value',
context: { value: it },
})
}
// eslint-disable-next-line @typescript-eslint/ban-types
export function _values<T extends Enumeration<T>>(clazz: Function): T[] {
const f = enums.get(clazz)
if (!f) {
throw new UnknownEnumerationClassError({ context: { clazz } })
}
return Object.values(f.byName)
}
/**
* Returns a convenient function to sort enumerations by their names.
*/
export const sortByName = <T extends Enumeration<T>>(
a: Enumeration<T>,
b: Enumeration<T>
) => a.name().localeCompare(b.name())
/**
* Returns a convenient function to sort enumerations by their ordinals.
*/
export const sortByOrdinal = <T extends Enumeration<T>>(
a: Enumeration<T>,
b: Enumeration<T>
) => a.ordinal() - b.ordinal()