Reputation: 610
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
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
.
Upvotes: 1