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.CLOSED
is 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