Manuel Mauky
Manuel Mauky

Reputation: 2193

Use enum value as generic parameter doesn't work with enums with more then one value

I've found a situation where it makes a difference if an Enum in typescript has 1 or more values and I don't get why.

Look at this simplified example code:

// library code

interface Action<T = any> {
    type: T
}

interface SpecialAction<T = any> extends Action<T> {
    id: string
    timestamp: Date
}

function enhance<T>(action: Action<T>): SpecialAction<T> {
    return {
        ... action,
        id: "some-id",
        timestamp: new Date()
    }
}

// users code

enum ActionTypes {
    A = "A"
}

interface UserAction extends SpecialAction<ActionTypes.A> {}

const value: UserAction = enhance({
    type: ActionTypes.A
})

This works perfectly fine. However, if I change the enum like this:

enum ActionTypes {
    A = "A",
    B = "B"
}

then the I get the following compile error in the line with const value: UserAction = enhance({:

Type 'SpecialAction<ActionTypes>' is not assignable to type 'UserAction'.
    Types of property 'type' are incompatible.
        Type 'ActionTypes' is not assignable to type 'ActionTypes.A'.

If I change the code to:

const value: UserAction = enhance<ActionTypes.A>({
    type: ActionTypes.A
})

the error is gone and everything works fine again.

So my assumption is that when the enum has only a single value then typescript infers the type T to be ActionTypes.A. But if the enum has more then one value then Typescript can't infer this anymore? But why is this the case? In the given example the information that T is ActionTypes.A is clearly defined in the object literal

{
    type: ActionTypes.A
}

But the more general question is: Why does the number of enum values matter for the compiler? Isn't this dangerous because it could break behavior in unexpected ways?

Upvotes: 2

Views: 778

Answers (1)

jcalz
jcalz

Reputation: 330501

There's a whole lot of stuff that happens when TypeScript encounters a value of a literal string/number/enum type. Sometimes the compiler will widen the type to string/number/the-relevant-enum. Other times the compiler will leave the type as the literal value. It's not incredibly simple to describe which one will happen where. You can read all about it if you dare.

In your case, you have a generic type parameter T in enhance() that you'd like to be inferred as the narrow literal type ActionTypes.A, but is actually inferred as ActionTypes, the union of each literal type that makes up the enum. If ActionTypes.A is the only element, then the types ActionTypes and ActionTypes.A are identical, and you don't see a problem. But when ActionTypes is equivalent to ActionTypes.A | ActionTypes.B, you run into the problem, because enhance()'s return type becomes SpecialAction<ActionTypes> and not SpecialAction<ActionTypes.A> like you expect. And you can't assign a SpecialAction<ActionTypes> value to a SpecialAction<ActionTypes.A> without checking it first or using a type assertion.

Again, you want T to be inferred as ActionTypes.A. Indeed, if you manually specify the type parameter T, as in enhance<ActionTypes.A>(...), it works. How can we get the compiler to infer the narrower type for T? Well, it turns out that if you place a special constraint that includes string or number on the generic type, the compiler will take that as a hint that you mean for the type to be as narrow as possible. For example:

declare function wide<T>(t: T): T;
let s = wide("abc"); // s is of type string
let n = wide(123); // n is of type number
declare function narrow<T extends string>(t: T): T;
let ss = narrow("abc"); // ss is of type "abc"
let nn = narrow(123); // error, 123 does not extend string 🙁

The special constraint can even be a union of types, as long as one of the elements of the union contains string or number and isn't absorbed by something else:

type Narrowable = string | number | boolean | symbol | object | void | undefined | null | {};
declare function betterNarrow<T extends Narrowable>(t: T): T; 
let sss = betterNarrow("abc"); // sss is of type "abc"
let nnn = betterNarrow(123); // nnn is of type 123
let bbb = betterNarrow(true); // bbb is of type true

Here, Narrowable is essentially the same as unknown or any in that just about anything is assignable to it. But since it is a union of things that contains string and number as elements it serves as a hint to narrow in generic constraints. (Note that type Narrowable = string | number | unknown wouldn't work, since that would become just unknown. It's tricky.)

So finally we can alter enhance() to give it that narrowing hint:

type Narrowable = string | number | boolean | symbol | object | void | undefined | null | {};
function enhance<T extends Narrowable>(action: Action<T>): SpecialAction<T> {
  return {
    ...action,
    id: "some-id",
    timestamp: new Date()
  }
} 

const value: UserAction = enhance({
  type: ActionTypes.A
}) // no error

It works! Hope that helps. Good luck.

Upvotes: 3

Related Questions