sydd
sydd

Reputation: 1946

How to type an object where function parameters are altered?

I am trying to make a method that receives an object where the values are methods, and it alters these methods by removing their first parameter. I almost got it working, but for some reason the function parameters and their return values are assigned randomly:

const obj = {
  f1: (p11: string, p12: number) => 5,
  another: (p21: string, p22: number) => "drt",
  aVoid: (p31: string, p32: string) => {}
}

// removes the first argument of a function
type CutFirstArg<F> = F extends (_: any, ...tail: infer TT) => infer R
  ? (...args: TT) => R
  : never

type ValueOf<T> = T[keyof T]

function test<T, K extends CutFirstArg<ValueOf<T>>>(theObject: T){
  const result = {}
  // some code that automatically alters the first param, this is just an example
  for (const [key, value] of Object.entries(theObject)) {
    (result as any)[key] = value.bind(null, "firstArmAddedManually", value.arguments.shift())
  }
  return result as Record<keyof T, K>
}
// this produces an error
const f1 = test(obj).another(21)

playground link

How should I type this properly? (also is there a simpler solution? This code looks like spaghetti)

Upvotes: 1

Views: 65

Answers (2)

jcalz
jcalz

Reputation: 328187

Your version doesn't work because T[keyof T] produces a union of all the property values in T, which in your case will be a union of functions. The keys have been forgotten, and any mapping between each key and each function is gone.

What you want instead is a mapped type which keeps track of this, like {[K in keyof T]: CutFirstArg<T[K]>}, in which each property key K from theObject's type is kept, and each property value T[K] is transformed via CutFirstArg.


My typing for this would look like:

function test<T extends Record<keyof T, (arg1: string, ...args: any) => any>>(
  theObject: T
) {
  const result: any = {}
  for (const [key, value] of 
    Object.entries(theObject) as Array<[keyof T, Function]>) {
    result[key] = (...args: any) => value("firstArmAddedManually", ...args)
  }
  return result as { [K in keyof T]: CutFirstArg<T[K]> }
}

Notice that I've constrained the input object to be something whose property values are functions whose first argument is of type string. This will prevent you from calling something like test({f: (arg: number) => arg.toFixed()}) which would blow up at runtime.

I've also changed some of the type assertions, but most noteworthy here is that I've changed your implementation to something that should actually work. I know you said "this is just an example", but it doesn't work since Function.arguments only has values inside a call to the function, assuming it works at all which it does not in many environments. Anyway, let's test it:

const testObj = test(obj);
/* const testObj: {
    f1: (p12: number) => number;
    another: (p22: number) => string;
    aVoid: (p32: string) => void;
} */

console.log(test(obj).another(21).toUpperCase()) // DRT

Looks good!

Playground link to code

Upvotes: 1

Kelvin Schoofs
Kelvin Schoofs

Reputation: 8718

I added a new ObjWithCutFirstArg type based on your CutFirstArg type. It's a mapping that basically maps functions to their cut version, and any other value just to themselves, to keep non-function fields of the object intact, in case you care:

const obj = {
    f1: (p11: string, p12: number) => 5,
    another: (p21: string, p22: number) => "drt",
    aVoid: (p31: string, p32: string) => { },
    manyArguments: (a: number, b: string, c: boolean) => true,
    varargFunc: (a: number, b: string, ...c: boolean[]) => false as const,
    noFunction: 123,
}

type ObjWithCutFirstArg<T> = {
    [K in keyof T]: T[K] extends (_: any, ...tail: infer TT) => infer R // if a function
        ? (...args: TT) => R // cut first argument
        : T[K]; // or use the original value
};

function test<T>(theObject: T): ObjWithCutFirstArg<T> {
    const result = {};
    // some code that automatically alters the first param, this is just an example
    for (const [key, value] of Object.entries(theObject)) {
        (result as any)[key] = value.bind(null, "firstArmAddedManually", value.arguments.shift())
    }
    return result as any;
}

const mapped = test(obj);
const f1: string = mapped.another(21);

const forIntellisense = { ...mapped };

The intellisense for mapped displays the following:

Resulting type for mapped

Which, when looking at forIntellisense, actually means the following:

Resulting type for forIntellisense

Upvotes: 1

Related Questions