Search code examples
typescripthigher-order-functionsmapped-types

Typescript generic mapped service type (change function parameters)


I have some object with functions as values, something like this:

const service = {
  methodA: (param1: TypeA, otherParam: string) => ({ a: 1 }),
  methodB: (param1: TypeA, otherParam: number) => ({ b: 2 }),
}

What I'm trying to achieve is to have some generic function, that would pass the first parameter to each of those methods, creating new service with one less param in each method:

const serviceWithInjectedParam = injectFirstParam(someValue, service)
serviceWithInjectedParam.methodA(otherValue) // this should at the end call service.methodA(someValue, otherValue)

The thing is, that I cannot figure out typings for injectFirstParam :( This is what I'm trying:

const injectFirstParam = <
  S extends { [method: string]: (param1: TypeA, ...params: unknown[]) => unknown }
>(
  param1: TypeA,
  service: S
) => {
  return (Object.keys(service) as (keyof S)[]).reduce((acc, key) => {
    acc[key] = (...params: unknown[]) => service[key](param1, ...params) // what should be params type here? I have TS error here
    return acc
  }, {} as { [key in keyof S]: S[key] extends (param1: TypeA, ...params: infer P) => infer R ? (...params: P) => R : never })
}

but TS says that

Type '(...params: unknown[]) => unknown' is not assignable to type 'S[keyof S] extends (param1: TypeA, ...params: infer P) => infer R ? (...params: P) => R : never'.

and when I try to use injectFirstParam I'm getting

Argument of type '{ methodA: (param1: TypeA, otherParam: string) => { a: number; }; methodB: (param1: TypeA, otherParam: number) => { b: number; }; }' is not assignable to parameter of type '{ [method: string]: (param1: TypeA, ...params: unknown[]) => unknown; }'.

Solution

  • In this case it is justified to use any instead of unknown because arguments are in contravariant position.

    type TypeA = string
    
    const service = {
        methodA: (param1: TypeA, otherParam: string) => ({ a: 1 }),
        methodB: (param1: TypeA, otherParam: number) => ({ b: 2 }),
    }
    
    const keys = <Obj extends Record<string, unknown>>(obj: Obj) =>
        Object.keys(obj) as Array<keyof Obj>
    
    type Reduce<Service, Type> = {
        [Key in keyof Service]:
        Service[Key] extends (param1: Type, ...params: infer P) => infer R
        ? (...params: P) => R
        : never
    }
    
    const injectFirstParam = <
        Service extends Record<PropertyKey, (param1: TypeA, ...params: any[]) => any>
    >(
        param1: TypeA,
        service: Service
    ) =>
        keys(service)
            .reduce((acc, key) => ({
                ...acc,
                [key]: <Param, Params extends Param[]>(...params: [...Params]) =>
                    service[key](param1, ...params)
            }), {} as Reduce<Service, TypeA>)
    
    const result = injectFirstParam('hello', service) // ok
    result.methodB(2) // ok
    

    See this simplified example:

    declare let unknown: unknown
    declare let string: string
    
    unknown = string // ok
    string = unknown // error
    

    string is assignable to unknown and unknown is not assignable to string. It is expected bahaviour.

    Because function arguments are in contravariant position, arrow of inheritance turns in opposite way.

    See this example:

    
    const contravariance = (cb: (arg: unknown) => void) => { }
    contravariance((arg: string) => { }) // error
    contravariance((arg: unknown) => { }) // ok
    

    As you might have noticed, now, callback with string argument is not assignable to to unknown, whereas unknown is assignable to string

    If you are interesting in this topic, please see my question.

    Further more, TypeScript does not like mutations. See my article and linked questions/answers