Reputation: 2193
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
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