简体   繁体   中英

Manage discriminated unions in TypeScript

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. or Aisles . Or AislesEnum .
  • 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. Eg, 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, eg, readonly food = new Aisle('food', '4') repeats 'food' . And this could suffer a mismatch between the property and the instance. Ideal would be new Aisle<'food'>() and then drive the actual name 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 implement nameof 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? Eg, 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' type AisleName = 'food' | 'housewares' | 'camping' 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 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?

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.

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> 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">

Playground link to code

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM