Functor
Functor

Reputation: 610

Type inference of Function composition method (chain) in TypeScript

I try to implement a function that is to extend a specified function to have a chainable method for function composition; as below;

Also see: TypeScript playground

{
  const F = <T, U>(f: (a: T) => U) => {
    type F = {
      compose: <V, >(g: (b: U) => V) => (((a: T) => V) & F);
    };
    return Object.defineProperty(f, "compose", {
      configurable: true,
      value: <V,>(g: (b: U) => V) => F((a: T) => g(f(a)))
    }) as ((a: T) => U) & F
  };
  //--------------------------------
  const f1 = (a: number) => a + 1;
  const f2 = (a: number) => a.toString();
  const identity = <T,>(a: T) => a;
  //--------------------------------
  const F2 = F(f2);
  // ((a: number) => string) & F  // good
  const F12 = F(f1).compose(f2);
  // ((a: number) => string) & F  // good
  //--------------------------------
  const F2i = (F2).compose(identity);
  // ((a: number) => string) & F  // good
  const f12i = (F12).compose(identity);
  // ((a: number) => number) & F  // bad  why??
  //--------------------------------
  const fi1 = F(identity).compose(f1);
  /* ts(2345) error
    Argument of type '(a: number) => number' is not assignable to parameter of type '(b: unknown) => number'.
        Types of parameters 'a' and 'b' are incompatible.
          Type 'unknown' is not assignable to type 'number'.
    const f1: (a: number) => number
  */
}

In this sample code, we have 3 basic functions; f1, f2 and identity.

F is the function that is to extend a specified function to have a method for function composition.

I managed to make it work somehow; however I found there are 2 issues at least.

1.

Now, we use F for f2, and the type of F2 is ((a: number) => string) & F that is to be expected.

Then, we use F for f1, and compose with f2, and the type of F12 is also ((a: number) => string) & F that is to be expected.

Therefore, the type of F2 and F12 is identical, so far, so good.

Now, The type of (F2).compose(identity) is ((a: number) => string) & F that is to be expected.

However, the type of (F12).compose(identity) is ((a: number) => number) & F that is Not to be expected.

I have traced my code for a long time, but I have no idea why this thing happens.

Can you give me advice? Thanks!

EDIT:

Please note the functions should not be wrapped in Object, and my intension is to provide a compose method Directly to the functions:

 const f = (a: number) => a + 1;
  const fg = f.compose(g);

  //not
  {
    f: f,
      compose:someFunction
  }

EDIT: for the #2 issue, with the comments by @jcalz, I created the separate question:

Is there any workaround for ts(2345) error for TypeScript lacks higher kinded types?

2.

As illustrated, I have ts(2345) error, and the error message does not make sense to me so that I have no idea how to fix this.

Upvotes: 1

Views: 328

Answers (1)

jcalz
jcalz

Reputation: 328292

The problem you're having should be apparent if you inspect the type of const F (the value, not the privately-named type) and unroll it a bit:

const alsoF: <T, U>(f: (a: T) => U) =>
  ((a: T) => U) & {
    compose: <V>(g: (b: U) => V) => (((a: T) => V) & {
      compose: <V>(g: (b: U) => V) => (((a: T) => V) & {
        compose: <V>(g: (b: U) => V) => (((a: T) => V) & {
          compose: <V>(g: (b: U) => V) => (((a: T) => V) & {
            compose: <V>(g: (b: U) => V) => (((a: T) => V) & any);
          });
        });
      });
    });
  } = F; // okay

(I bailed out with any after a few levels deep, but you don't need to do that if you create new named types; it doesn't matter for this discussion, though.) If the function you pass to F is of type (a: T) => U for some U, the returned function has a compose method accepts a callback whose first parameter is of type U. That's fine, but then when you call the compose method of that, your new thing's compose method is of the same type as the old one. It wants a callback whose parameter is of type U. But you wanted a callback whose parameter is of type V, the return type of the callback previously passed to compose(). And this problem persists forever: each call to compose() will return another thing whose compose() expects a callback accepting a parameter of type U and not the return type of the previous call to compose().

So that means your F(f1) returns the following type:

const fF1 = alsoF(f1);
/* const alsoF12: ((a: number) => string) & {
compose: <V>(g: (b: number) => V) => ((a: number) => V) & {
    compose: <V>(g: (b: number) => V) => ((a: number) => V) & {
        ...;
    };
}; } */

And your F12 is therefore of the following type:

const alsoF12 = alsoF(f1).compose(f2);
/* const alsoF12: ((a: number) => string) & {
compose: <V>(g: (b: number) => V) => ((a: number) => V) & {
    compose: <V>(g: (b: number) => V) => ((a: number) => V) & { ... };
};  } */

And oops, it will always expect the callback to compose() to take a number, not a string like you want. And so your f12i is this:

const alsoF12i = (alsoF12).compose(identity);
/* const alsoF12i: ((a: number) => number) & {
compose: <V>(g: (b: number) => V) => ((a: number) => V) & {
    compose: <V>(g: (b: number) => V) => ((a: number) => V) & { ...  };
  }; } */

And that's not what you want.


To fix this, you will need the type returned from compose() to keep track of both the T type from the original callback parameter, and the "current" U type which is the return type of the most recently passed-in parameter. So the F type you defined should be generic in U in order to represent this. Let's pull it out of the function implementation and describe F<T, U> as what comes of out a call to F(f) where f is of type (a: T) => U:

interface F<T, U> {
  (a: T): U;
  compose<V>(g: (b: U) => V): F<T, V>;
}

So it has a call signature which takes a value of type T and returns U. It also has a generic compose method that takes a callback of type (b: U) => V for generic V, and returns an F<T, V>. It is this substitution of V for U that lets the chain keep track of the most recent callback return type.

And here's the implementation:

const F = <T, U>(f: (a: T) => U): F<T, U> => {
  return Object.defineProperty(f, "compose", {
    configurable: true,
    value: <V,>(g: (b: U) => V) => F((a: T) => g(f(a)))
  }) as any;
};

And let's try it out:

const f1 = (a: number) => a + 1;
const f2 = (a: number) => a.toString();
const identity = <T,>(a: T) => a;
//--------------------------------
const F2 = F(f2);
// const F2: F<number, string> 
const F12 = F(f1).compose(f2);
// const F12: F<number, string>
//--------------------------------
const F2i = (F2).compose(identity);
// const F2i: F<number, string>
const f12i = (F12).compose(identity);
// const f12i: F<number, string>

console.log(f12i(123).repeat(2)) // "124124"

Looks good. The compiler understands now that f12i is of type F<number, string> so when you call it, it will return a string.


Playground link to code

Upvotes: 1

Related Questions