0

How would I type the following transformer function?

class MyClass {
  public id: string = ''
}

const instance = new MyClass()

function transformer(funcs) {
  return Object.fromEntries(
    Object.entries(funcs)
     .map(([key, func]) => [key, func.bind(instance)])
  )
}

The crux of my conundrum: More than just passing muster with the linter and compiler, I want intelligent typing for this, such that passing in an object with various string keys bonded to function values (each which expect a this arg of type MyClass) gets transformed such that the output is identical to the input except that it's had its requisite this param "burnt-in," and this is known to the editor/linter/compiler.

In fact, I can't even solve for the simpler solitary case, a function that takes a single param of a function needing a this of type MyClass, along with any number of additional params, and a certain return type… and it spits back a function that's typed identically, except its this has been "burnt-in."

function transform(fn) {
  return fn.bind(new MyClass())
}

Even partial answers or further insight would be helpful here! I'd think that we'd need some clever and deep use of generics here, but don't even exactly know where to start. And any answers that can point to further documentation or reference material on the concepts used are especially appreciated!

2 Answers 2

1

You can do this via using mapped types and a conditional type to strip the this param. I also modified your transformer function to be generic on the instance but still require that the function's this argument is the type of the instance provided to avoid code dupe later on. Here is my solution:

class MyClass {
  public id: string = ''
}

const instance = new MyClass()

type StripThisParam<T extends (...args: any[]) => any> =
    T extends (this: any, ...params: infer $RestParams) => any
        ? (...args: $RestParams) => ReturnType<T>
        : T;

function transformer<
    I,
    T extends Record<string, (this: I, ...otherArgs: any[]) => any>
>(instance: I, funcs: T): { [K in keyof T]: StripThisParam<T[K]> } {
  return Object.fromEntries(
    Object.entries(funcs)
     .map(([key, func]) => [key, func.bind(instance)])
  ) as any;
}

const foo = transformer(instance, {
    bar(this: MyClass, name: string, age: number) {
        console.log(this.id);
    }
});

foo.bar("hey", 23);

TypeScript Playground Link

Sign up to request clarification or add additional context in comments.

1 Comment

Thank you so much! The answer above uses one of TS's built-ins to accomplish what you've described, but thanks so much for your answer, as you've seemed to have done what it does manually with your code snippet above! Gonna dissect it and learn from it, so thank you tons!
1

A function type can include the type of this inside the function. For example:

const f: (this: MyClass, x: number) => string =
  function(this: MyClass, x: number): string { /* ... */}

There’s a builtin utility type OmitThisParameter that can remove or ‘burn in’ this this parameter:

/**
 * Extracts the type of the 'this' parameter of a function type,
 * or 'unknown' if the function type has no 'this' parameter.
 */
type ThisParameterType<T> = T extends (this: infer U, ...args: never) => any
  ? U
  : unknown

/**
 * Removes the 'this' parameter from a function type.
 */
type OmitThisParameter<T> = unknown extends ThisParameterType<T>
  ? T
  : T extends (...args: infer A) => infer R ? (...args: A) => R : T

You can type transform and transformer like this:

type MyClassFn = (this: MyClass, ...args: never[]) => unknown

function transform<F extends MyClassFn>(fn: F): OmitThisParameter<F> {
  // requires a type cast here: fn.bind doesn't seem to have an overload
  // for a generic number of parameters with possibly different types
  return (fn.bind as (thisArg: MyClass) => OmitThisParameter<F>)(instance)
}

function transformer<T extends Record<string, MyClassFn>>(
  funcs: T
): {[K in keyof T]: OmitThisParameter<T[K]>} {
  return Object.fromEntries(
    Object.entries(funcs)
     .map(([key, func]) => [key, func.bind(instance)])
  ) as {[K in keyof T]: OmitThisParameter<T[K]>}
}

// Usage
declare const f1: (this: MyClass, x: number, y: string) => number
declare const f2: (this: MyClass, x: number, y: boolean) => string
declare const f3: (this: MyClass) => void

// (x: number, y: string) => number
transform(f1)

// (x: number, y: boolean) => string
transform(f2)

// () => void
transform(f3)

// {
//   f1: (x: number, y: string) => number
//   f2: (x: number, y: boolean) => string
//   f3: () => void
// }
transformer({f1, f2, f3})

Playground link

3 Comments

Goodness, thank you so much, @cherryblossom!!! Going to try this shortly! Exactly what I was hoping for!!! If you don't mind one addtl. question… how is it that …args: never[] works here? Obviously, on the other side of all this typing, the function signatures remain intact. I guess it's down to how that and extends interact?
Function parameters are contravariant, so all functions will be compatible with ...args: never[]. If we used ...args: unknown[], it would mean that the caller can supply any arguments, such as f1('oops this should be a number', true) (f1 should actually take a number and a string), which is why f1 is not assignable to (...args: unknown[]) => unknown. On the other hand, function return types are covariant, which is why unknown is used. More details: stackoverflow.com/a/67717998/8289918
You could use any[] instead of never[] as well, which I've seen more often in this situation than never[], but I tend to avoid any in my code.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.