Question

Common function for different enums

I have multiple enums

const enum E1 {
    CLOSED = 'CLOSED',
    OPEN = 'OPEN',
    IN_PROGRESS = 'IN_PROGRESS',
}

const enum E2 {
    OPEN = 'OPEN',
    CLOSED = 'CLOSED',
    IN_PROGRESS = 'IN_PROGRESS',
}

const enum E3 {
    OPEN = 'OPEN',
    IN_PROGRESS = 'IN_PROGRESS',
}

I want to create a function that accepts an enum that one of the values should be 'CLOSED'

const func = (value: 'CLOSED') => {...}
interface Data1 {status: E1}
interface Data2 {status: E2}
interface Data3 {status: E3}
const o1: Data1 = {status: E1.CLOSED}
const o2: Data2 = {status: E2.OPEN}
const o3: Data3 = {status: E3.OPEN}

func(o1.status)   // this should be valid. status is type E1, it contains 'CLOSED'
func(o2.status)   // this should be valid. status is type E2, it contains 'CLOSED'
func(o3.status)   // this should be invalid. status type is E3, it does not contain 'CLOSED'

Not it throws an error for each of them:

Argument of type E... is not assignable to parameter of type "CLOSED"

I don't want that function to know about each enum, as they are many. This is why I used the type value: 'CLOSED'

Do I need something like a common enum to make others extend it somehow, so I can use its type in the argument instead of 'CLOSED'?

Typescript version: 4.9.4

 2  43  2
1 Jan 1970

Solution

 1

Enums have some weird type system behavior which make this more complicated. For now, let's just use plain string literal types instead of enums and then we can come back to it. We can replace your enum definitions with const-asserted objects and type aliases for now. E1 looks like

const E1 = {
    CLOSED: 'CLOSED',
    OPEN: 'OPEN',
    IN_PROGRESS: 'IN_PROGRESS',
} as const
type E1 = typeof E1[keyof typeof E1]

and the others are analogous.


The type (value: "CLOSED") => void doesn't work for you because that means whatever you pass in for value has to be assignable to the literal type "CLOSED". That's backwards from what you want. Instead, you want to say that "CLOSED" has to be assignable to the type of value. Instead of bounding the type of value from above (e.g., it must be "CLOSED" or some narrower type), you want to bound it from below (e.g., it must be "CLOSED" or some wider type).

And unfortunately TypeScript doesn't natively support such lower bound type constraints. TypeScript's constraints (e.g., T extends U) are upper bounds. There's an open feature request at microsoft/TypeScript#14520 to allow for lower bound constraints (e.g., T super U). If that were part of TypeScript, then I'd say you could write

// not valid TS, don't try this:
const func = <T extends string super 'CLOSED'>(value: T) => { }

and be done. You'd be constraining value from above by string and from below by "CLOSED". But you can't do this directly.

What you can do as a workaround is to leverage conditional types in your constraint. Conceptually T super "CLOSED" is the same as "CLOSED" extends T. So if we can rewrite our constraint so that "CLOSED" extends T is enforced, then it might behave how you want:

const func = <T extends 'CLOSED' extends T ? string : never>(value: T) => { }

func(o1.status)   // okay
func(o2.status)   // okay
func(o3.status)   // error
func(Math.random() < 0.5 ? "CLOSED" : "XYZ"); // okay
func(Math.random() < 0.5 ? "ABC" : "XYZ"); // error

And it does work. When you call func(o1.status), T is inferred as E1. Then the constraint becomes 'CLOSED' extends E1 ? string : never, which collapses to string, and so the constraint is T extends string which is met and it succeeds.

But when you call func(o3.status), T is inferred as E3. Then the constraint becomes 'CLOSED' extends E3 ? string : never, which collapses to the never type, and so the constraint is T extends never which is unmet and it fails.


That's how I'd do it if you weren't using enums. Enums make it more complicated, because they are themselves mostly considered proper subtypes of their values. So E1.CLOSED extends "CLOSED" is true, but "CLOSED" extends E1.CLOSEDis false:

type Yes = E1.CLOSED extends "CLOSED" ? true : false
//   ^? type Yes = true
type No = "CLOSED" extends E1.CLOSED ? true : false
//   ^? type No = false

And that means "CLOSED" isn't a lower bound on E1, even though it's a lower bound on the string literal types E1 represents. Rather than try to dissect enums, I'll use a trick to widen string enums to their string literal types: just use template literal types to serialize them:

type E1Serialized = `${E1}`
//   ^? type E1Serialized = "CLOSED" | "OPEN" | "IN_PROGRESS"

And so "CLOSED" is a lower bound on E1Serialized. That means we can change func to be

const func = <T extends 'CLOSED' extends `${T}` ? string : never>(value: T) => { }

And everything works:

func(o1.status)   // okay
func(o2.status)   // okay
func(o3.status)   // error
func(Math.random() < 0.5 ? "CLOSED" : "XYZ"); // okay
func(Math.random() < 0.5 ? "ABC" : "XYZ"); // error

Playground link to code

2024-07-23
jcalz

Solution

 0

Could this work for you?

  1. Create types that are union types (of string literal types) instead of enums:
type CLOSED<T extends string> = `${T}.CLOSED`;
type OPEN<T extends string> = `${T}.OPEN`;
type IN_PROGRESS<T extends string> = `${T}.IN_PROGRESS`;

type EWithClosed<T extends string> = CLOSED<T> | OPEN<T> | IN_PROGRESS<T>;
type EWithoutClosed<T extends string> = Exclude<EWithClosed<T>, CLOSED<T>>;
  1. Create values that can be used at runtime:
const makeClosed = <T extends string>(k: T): CLOSED<T> => `${k}.CLOSED`;
const makeOpen = <T extends string>(k: T): OPEN<T> => `${k}.OPEN`;
const makeInProgress = <
    T extends string
>(k: T): IN_PROGRESS<T> => `${k}.IN_PROGRESS`;

const makeWithClosed = <T extends string>(k: T) => ({
  CLOSED: makeClosed(k),
  OPEN: makeOpen(k),
  IN_PROGRESS: makeInProgress(k)
});

const makeWithoutClosed = <T extends string>(k: T) => ({
  CLOSED: makeClosed(k),
  OPEN: makeOpen(k)
});

const E1 = makeWithClosed("E1");
const E2 = makeWithClosed("E2");
const E3 = makeWithoutClosed("E3");
  1. We can then use the types and values:
const o1 = {status: E1.CLOSED }
const o2 = {status: E2.OPEN }
const o3 = {status: E3.OPEN }

const func = <T extends string>(value: CLOSED<T>) => {
  // ...
}

func(o1.status)   // OK
func(o2.status)   // ERROR
func(o3.status)   // ERROR

Check this demo

2024-07-23
Remo H. Jansen