Yoav Avrahami
Yoav Avrahami

Reputation: 46

Filtering keyof in typescript mapped types works unexpectedly

I am trying to extract the keys of an interface (using keyof) that match an event handler pattern - that is (CustomEvent) => void.

I have a method that seems to work and does extract only keys that match (CustomEvent) => void, however, it also extracts keys who's type is () => void.

Is there a way to extract only keys of types that conform to (CustomEvent) => void?

The method is based on the questions Filter interface keys for a sub-list of keys based on value type and Typescript : Filter on keyof type parameter

In the example below

export interface JayCustomEvent {}

type EventHandler = (e: JayCustomEvent) => void;

type FilteredKeys<T, U> = { [P in keyof T]: P extends string ? (T[P] extends U ? P : never) : never}[keyof T];

interface AComponentEvent extends JayCustomEvent {}
interface AComponent {
    anEvent(e: AComponentEvent): void,
    noParamFunction(): void,
    someOtherFunction(a: number): void
    someProp: string
}

let a: FilteredKeys<AComponent, EventHandler> = 'anEvent'
let b: FilteredKeys<AComponent, EventHandler> = 'noParamFunction'
let c: FilteredKeys<AComponent, EventHandler> = 'someOtherFunction'
let d: FilteredKeys<AComponent, EventHandler> = 'someProp'

I expect assignment a to be working, while b, c and d should not. However, assignments a and b are valid, while only c and d are not.

Upvotes: 0

Views: 1273

Answers (2)

Yoav Avrahami
Yoav Avrahami

Reputation: 46

I have solved exactly what I need, so posting here to help others.

Given the definitions

type Func0 = () => void
type Func1 = (x: any) => void
type DOMeventHandler<E> = (((this: GlobalEventHandlers, ev: E) => any) | null)

As @jsejcksn noted in his answer (which is correct), Func0 does extend Func1. However, we can use this to construct a type that matches exactly the keys of an object who are one parameter functions.

The answer is here, following explanation

type EventHandlerKeys<T> = {
    [P in keyof T]:
    P extends string ?
        (T[P] extends Func1 ?
            (T[P] extends Func0 ? never : P) :
            T[P] extends DOMeventHandler<any> ? P : never) :
        never
}[keyof T];

The trick is to match on Func1, which selects both functions of zero or one parameter. Then we match again on Func0 and return never for zero parameter functions, and the actual property type for one parameter functions.

Here we also match on DOMEventHandler as an additional bonus with another conditional branch.

Upvotes: 0

jsejcksn
jsejcksn

Reputation: 33739

Your mapping utility seems fine.

I think you might be surprised to learn that functions with lower arity are subtypes of compatible higher-arity ones. Consider the following example:

type AssignableTo<T, U> = T extends U ? true : false;

type Fn0Params = () => void;
type Fn1Param = (p: unknown) => void;

declare const ex1: AssignableTo<Fn0Params, Fn1Param>;
ex1 // true

type Fn0ParamsNever = (...args: never) => void;

declare const ex2: AssignableTo<Fn0ParamsNever, Fn1Param>;
ex2 // false

So, by typing your noParamFunction as having parameters of type never, you can prevent it from being assignable to EventHandler:

noParamFunction(...args: never): void

TS Playground

Perpendicular to your question: If you're actually working with CustomEvents, then you might want to use (event: CustomEvent<JayCustomEvent>) => void

Upvotes: 1

Related Questions