Enums Won't Work
TypeScript enums come close to what I need, but they don't get all the way there. I need a known-at-compile-time and static-at-runtime set of items that act like an enum, but are instances of a class to enable adding functionality such as properties or methods.
Assume TypeScript 4.4.3 for the purposes of this question.
Example
As an example, say a small convenience store has three aisles. Each aisle has an id and a name:
// Aisle objects
name: 'food', aisleId: '4'
name: 'housewares', aisldId: '7'
name: 'camping', aisleId: '9'
When the name or aisleId of an aisle is contained in a variable, some static helper methods on the Aisles class make sense:
static getByName(name: string): Aisle {
// return the right Aisle, or return undefined, or maybe throw
}
static getById(aisleId: string): Aisle {
// same, but for aisleId
}
(There might be a description property, and others, but there won't be any lookup on Aisle objects by description.)
If some code wants to work with a particular Aisle, it would be nice to just write Aisles.Food, or Aisles['7']. While the above getBy methods could be used, it's not wonderful to have to use Aisles.getById('food') when 'food' is known at compile-time. If those have to be in different enums such as AisleNames.Food or AisleIds['7'], that would be fine.
Goals
It would be great to achieve all, or most, of these goals:
- Code that only works with passed-in
Aisleinstances should be able to rely on ambient declarations, without needing to import anything:function cleanAisle(aisle: Aisle) {}shouldn't have to import anything, because it's not creating any instances. - An enum-like collection of all aisles can be exposed somewhere:
AisleListperhaps. orAisles. OrAislesEnum. - Ambient type declarations canonically drive type-safety throughout the code base. It would not be great to need two potentially-conflicting sources of type/class information, one
.tsfile that exposes an enum or enum like object, and a separate one in an ambient.d.tsfile that shadows the enum, with neither being the single canonical source. - A type error should occur somewhere if there are any inconsistencies: if any bit of code has a new aisle added, or removed, or misnamed, or misspelled, or there are any duplicates where there shouldn't be.
- The scheme has the least possible amount of code repetition. E.g., it's not great to name a property, but pass that same name as a string into a constructor just so the class instance can be used to return its own name, e.g.,
readonly food = new Aisle('food', '4')repeats'food'. And this could suffer a mismatch between the property and the instance. Ideal would benew Aisle<'food'>()and then drive the actualnameproperty or use of the name as a code symbol, everywhere, deriving from either the ambient type declaration or from the right instance of the class. (There is a TypeScript plugin to implementnameofat compile-time that might be helpful, but I haven't gotten that far yet.)
My Attempts
In my attempts to achieve these goals, several problems showed up. (You can see some of my noodling in the TypeScript Playground, but not everything I tried is in there.)
I tried adding a generic type parameter to Aisle, such as Aisle<'food'>, but when working to put those in a collection, how do I use an interface for them? E.g., given interface Aisle<T extends AisleName> {} and a concrete class ConcreteAisle<T extends AisleName> implements Aisle<T> {}, one can't put instances of ConcreteAisle<T> into Aisle[] because Aisle wants a generic type argument, but there is no single generic type to give that. Would a union work there, such as Aisle<AisleName> where type AisleName = 'food' | 'housewares' | 'camping'? Then the uniqueness/completeness problem rears its head. A Set<Aisle> won't guarantee uniqueness. Here's where "enums in TypeScript are not great" comes in.
enum Aisle as const could help, if only the code didn't require "isolatedModules": true in tsconfig.json. So that's not an option.
Also had some trouble with methods getAisleById() and getAisleByName(). Using a Set of Aisle objects, and doing a .map() over them in order to generate a Map<AisleName, Aisle> and a Map for aisleId ran into the problem that even if a type union is used for the passed-in value, the Map<K, V>#get method returns type V | undefined, requiring some kind of workaround to trick TypeScript into believing that the result is known to not be undefined so long as the value is in the union type. I know how to use functions that return var is Type as the return value, or asserts var is Type, but would rather not expose those to the consumers of these objects—that should all be internal to the implementation of Aisles and Aisle enum-like constructs, if possible.
If necessary, I could write a separate concrete class for each Aisle. that would be fine, and would let me hard-code the aisleName and aisleId in each one instead of having to pass those values into a constructor. But the "how to work with these in a collection that functions as a discriminated union" problem becomes worse.
Does anyone have any ideas?