MOre information about same problem you can find here
For the sake of brevity we can reduce your example to this:
interface ClientEvents {
foo: [1],
bar: [2, 2]
}
type Event<T extends keyof ClientEvents> = {
name: T
execute: (...args: ClientEvents[T]) => Promise<unknown>
}
export const events: Array<Event<keyof ClientEvents>> = []
export function registerEvent<T extends keyof ClientEvents>(
name: T,
execute: (...args: ClientEvents[T]) => Promise<unknown>
) {
events.push({ name, execute }) // errors at execute
}
Since you want to infer T
, I assume this is a valid call:
const result = registerEvent(
'foo', (...args: [1]) => null as any
) // should compile
const result2 = registerEvent(
'foo', (...args: [2,2]) => null as any
) // should fail
It is pretty straitforward. But wait, since T extends keyof ClientEvents
I can pass a union as a type for ...args
:
const result2 = registerEvent(
'foo', (...args: [2,2] | [1]) => null as any
) // still compiles
Let's slightly change the function implementation. Le'ts just return an event.
export function registerEvent<T extends keyof ClientEvents>(
name: T,
execute: (...args: ClientEvents[T]) => Promise<unknown>
): Event<keyof ClientEvents> {
return { name, execute } // <--- error
}
You will get this error:
The intersection '1 & [2, 2]' was reduced to 'never' because property '0' has conflicting types in some constituents
WHy this erorr occured?
multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred.
Hence [1] & [2, 2] === never
. execute
is in contra-variant position with ClientEvents[T]
. This behavior is by design and makes your code much safer.
Consider this example:
interface ClientEvents {
foo: [1],
bar: [2, 2]
}
type Event<T extends keyof ClientEvents> = {
name: T
execute: (...args: ClientEvents[T]) => Promise<unknown>
}
export const events: Array<Event<keyof ClientEvents>> = []
export function registerEvent<T extends keyof ClientEvents>(
name: T,
execute: (...args: ClientEvents[T]) => Promise<unknown>
): Event<keyof ClientEvents> {
return { name, execute } // <------- error here
}
let invalid = (...args: [1] | [2, 2]) => Promise.resolve(args[0])
const result2 = registerEvent(
'foo', invalid
) // still compiles
// can lead to runtime error, since "foo" expects 1
result2.execute(2, 2)
TS still is trying to tell you that something might go wrong and you should not do it.
Such behavior was not always. Previously, all function was bivariant to their arguments. Try to turn off strictFunctionTypes
flag. You will see that your code will compile.
Let's try to go another way. Maybe we should to declare a type of all possible allowed events?
interface ClientEvents {
foo: [1],
bar: [2, 2]
}
type Event<T extends keyof ClientEvents> = {
name: T
execute: (...args: ClientEvents[T]) => Promise<unknown>
}
type Values<T> = T[keyof T]
type ValidEvents = Values<{
[Prop in keyof ClientEvents]: Event<Prop>
}>
export const events: Array<ValidEvents> = []
export function registerEvent(event: ValidEvents) {
events.push(event)
return event
}
let invalid = (...args: [1] | [2, 2]) => Promise.resolve(args[0])
const result2 = registerEvent(
{ name: 'foo', execute: invalid }
) // still compiles
// but now you are not able to call execute with invalid arguments
result2.execute(2, 2) //
Above approach requires a bit of refactor but is also a bit safer
UPDATE
If for some reason you don't want to refactor your function, you may overload it:
interface ClientEvents {
foo: [1],
bar: [2, 2]
}
type Event<T extends keyof ClientEvents> = {
name: T
execute: (...args: ClientEvents[T]) => Promise<unknown>
}
type Values<T> = T[keyof T]
type ValidEvent = Values<{
[Prop in keyof ClientEvents]: Event<Prop>
}>
export const events: Array<ValidEvent> = []
// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I
) => void
? I
: never;
type Overloading =
UnionToIntersection<Values<{
[Prop in keyof ClientEvents]: (...args: [
name: Prop,
execute: (...args: ClientEvents[Prop]) => Promise<unknown>
]) => void
}>>
const registerEvent: Overloading = (
name: keyof ClientEvents,
execute: (...args: any[]) => Promise<unknown>
) => {
events.push({ name, execute }) // errors at execute
}
let invalid = (...args: string[]) => Promise.resolve(args[0])
let valid = (...args: [1]) => Promise.resolve(args[0])
const result2 = registerEvent(
'foo', invalid
) // error, despite the fact that we have an any[]
const result3 = registerEvent(
'foo', valid
) // ok
Be aware the intersection of function types produces overloadings.
however when using ClientEvents from the library, execute still produces an
My bad, I've been working with mocked data. TS can't bind together name
and execute
. Seems ClientEvents
is a much complicated datastructure than I thought. Hence, you better stick with first solution, where name
and execute
are properties of same object.
export function registerEvent(event: ValidEvents) {
events.push(event)
return event
}