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
Aisle
instances 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:
AisleList
perhaps. 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
.ts
file that exposes an enum or enum like object, and a separate one in an ambient.d.ts
file 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 actualname
property 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 implementnameof
at 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?
CodePudding user response:
To be able to discriminate over a union of types, there must be at least one common property between the types that has a literal value (boolean, string, number, or unique symbol). You were on the right path by adding a T extends ...
property to your Aisle
.
Here is a possible approach that pulls out both the id
and the name
parameters from your Aisle
class for maximum compile-time inferabilty:
// `id` and `name` will both be pulled out as constants here, allowing you to get
// their exact values later on.
class Aisle<Id extends number, N extends string> {
constructor(readonly id: Id, readonly name: N) { }
}
// Here's an "enum" of statically known Aisles
const Aisles = {
1: new Aisle(1, 'baking'),
2: new Aisle(2, 'garden'),
3: new Aisle(3, 'spices'),
4: new Aisle(4, 'food'),
5: new Aisle(5, 'toys'),
6: new Aisle(6, 'cleaning'),
7: new Aisle(7, 'housewares'),
8: new Aisle(8, 'travel'),
9: new Aisle(9, 'camping'),
} as const; // Don't forget this
// And here's a type that makes referencing specific Aisles easier
type Aisles = typeof Aisles;
// Like so
type AisleOneOrTwo = Aisles[1] | Aisles[2];
// Accessing a particular Aisle
const a0 = Aisles[1] // Aisle<1, "baking">
// Fields are resolved to their literal values here.
a0.id // 1
a0.name // "baking"
// Here's a full union type of your Aisles.
type AislesUnion = Aisles[keyof Aisles];
Here's a link to the playground as well.
CodePudding user response:
As you note, enum
values in TypeScript are really just primitive strings or numbers. They don't behave like enum
in Java which are just class instances under the hood. If you want enum-like class instances, you'll have to build them yourself.
One approach could be to not export the underlying class (so that new instances you don't control don't show up everywhere) and make sure they are strongly typed enough so that the resulting enums can behave as a discriminated union by having a literal-valued discriminant property. Looks like you have two such properties, name
and aisleId
, so let's make this underlying Aisle
class generic in both of these:
class Aisle<N extends string, I extends string> {
constructor(public name: N, public aisleId: I, public description: string) { }
someMethod() {
console.log("Hi, I'm " this.name " and my Id is "
this.aisleId " and my description is " this.description);
}
}
Since we want the exported Aisles
enum-like object to have keys for each of these name
and aisleId
values, we can write a helper function which returns an object with both such keys where each value is the relevant class instance:
function make<N extends string, I extends string>(name: N, aisleId: I, description: string) {
const aisle = new Aisle(name, aisleId, description);
return { [name]: aisle, [aisleId]: aisle } as Record<N | I, typeof aisle>;
}
Note that the return type has to be asserted as Record<N | I, typeof aisle>
because the compiler unfortunately widens computed property key types all the way to string
; see microsoft/TypeScript#13948.
Now we can build our exported Aisles
enum object thing with object spreading:
export const Aisles = {
...make("food", "4", 'All things edible'),
...make("housewares", "7", 'Make your home zing'),
...make("camping", "9", 'Get into the outdoors!')
} as const;
You can inspect Aisles
to check that it has all the keys and values you care about.
/* const Aisles: {
readonly camping: Aisle<"camping", "9">;
readonly 9: Aisle<"camping", "9">;
readonly housewares: Aisle<"housewares", "7">;
readonly 7: Aisle<"housewares", "7">;
readonly food: Aisle<...>;
readonly 4: Aisle<...>;
} */
If you want Aisles
to also be a type corresponding to the values of the Aisles
object, (enum
s do this automatically), you can do that:
export type Aisles = typeof Aisles[keyof typeof Aisles];
If you want to attach more types to Aisles
you can do it via a namespace
; for example, the Name
and Id
types corresponding to the union of relevant keys:
namespace Aisles {
export type Name = Aisles extends infer A ? A extends Aisle<infer N, any> ? N : never : never;
export type Id = Aisles extends infer A ? A extends Aisle<any, infer I> ? I : never : never;
}
export default Aisles
That's has, I hope, most of the features you care about. You can access the name
and aisleId
keys of Aisles
:
Aisles.food.someMethod();
// "Hi, I'm food and my Id is 4 and my description is All things edible"
Aisles[7].someMethod();
// "Hi, I'm housewares and my Id is 7 and my description is Make your home zing"
You can use the Aisles
type as a discriminated union:
function processAisle(aisle: Aisles) {
switch (aisle.name) {
case "camping": return 0;
case "food": return 1;
// not all paths return a value unless you uncomment next line
/* case "housewares": return 2; */
}
}
You can use the exported Aisles.Name
type to constrain keys to just the name
and not the aisleId
properties:
function reImplementGetAisleByName<N extends Aisles.Name>(name: N) {
return Aisles[name]
}
const camping = reImplementGetAisleByName("camping");
// const camping: Aisle<"camping", "9">