Typing compose function in TypeScript (Flow $Compose)
Asked Answered
W

6

15

In flow there is support for $Compose functions (see recompose as example). However, I can't seem to find such a mechanism in typescript. it seems that the best typescript can do is something like https://github.com/reactjs/redux/blob/master/index.d.ts#L416-L460. What's the equivalent to $Compose in Typescript?

EDIT: What I'm trying to accomplish is to type the compose function from recompose or redux such that it's typesafe. In particular, with react higher order components, I want to ensure that the output props of one HOC satisfy the input props of the next HOC. This is my current workaround and seems to work reasonably well - though I was hoping there is a good way to do this natively in typescript.

/** Wraps recompose.compose in a type-safe way */
function composeHOCs<OProps, I1, IProps>(
  f1: InferableComponentEnhancerWithProps<I1, OProps>,
  f2: InferableComponentEnhancerWithProps<IProps, I1>,
): ComponentEnhancer<IProps, OProps>
function composeHOCs<OProps, I1, I2, IProps>(
  f1: InferableComponentEnhancerWithProps<I1, OProps>,
  f2: InferableComponentEnhancerWithProps<I2, I1>,
  f3: InferableComponentEnhancerWithProps<IProps, I2>,
): ComponentEnhancer<IProps, OProps>
function composeHOCs<OProps, I1, I2, I3, IProps>(
  f1: InferableComponentEnhancerWithProps<I1, OProps>,
  f2: InferableComponentEnhancerWithProps<I2, I1>,
  f3: InferableComponentEnhancerWithProps<I3, I2>,
  f4: InferableComponentEnhancerWithProps<IProps, I3>,
): ComponentEnhancer<IProps, OProps>
function composeHOCs(
  ...fns: Array<InferableComponentEnhancerWithProps<any, any>>
): ComponentEnhancer<any, any> {
  return compose(...fns)
}
Waistcloth answered 15/3, 2018 at 23:27 Comment(2)
$Compose is undocumented and your link isn't clear. Can you provide a more detailed explanation (perhaps with examples) of what it does / what you want to do?Provocation
@RyanCavanaugh clarified.Waistcloth
P
18

I read your question as follows:

How can I give a TS type to this higher-order function, such that the type of x is allowed to vary across the loop?

function compose(...funs) {
    return function(x) {
        for (var i = funs.length - 1; i >= 0; i--) {
            x = funs[i](x);
        }
        return x;
    }
}

The bad news is that you can't type this function directly. The funs array is the problem - to give compose its most general type, funs should be a type-aligned list of functions - the output of each function must match the input of the next. TypeScript’s arrays are homogeneously typed - each element of funs must have exactly the same type - so you can't directly express the way the types vary throughout the list in TypeScript. (The above JS works at runtime because the types are erased and data is represented uniformly.) That's why Flow's $Compose is a special built-in type.

One option to work around this is to do what you've done in your example: declare a bunch of overloads for compose with varying numbers of parameters.

function compose<T1, T2, T3>(
    f : (x : T2) => T3,
    g : (x : T1) => T2
) : (x : T1) => T3
function compose<T1, T2, T3, T4>(
    f : (x : T3) => T4,
    g : (x : T2) => T3,
    h : (x : T1) => T2
) : (x : T1) => T4
function compose<T1, T2, T3, T4, T5>(
    f : (x : T4) => T5,
    g : (x : T3) => T4,
    h : (x : T2) => T3,
    k : (x : T1) => T2
) : (x : T1) => T5

Clearly this doesn't scale. You have to stop somewhere, and woe betide your users if they need to compose more functions than you expected.

Another option is to rewrite your code such that you only compose functions one at a time:

function compose<T, U, R>(g : (y : U) => R, f : (x : T) => U) : (x : T) => R {
    return x => f(g(x));
}

This rather muddies up the calling code - you now have to write the word compose, and its attendant parentheses, O(n) times.

compose(f, compose(g, compose(h, k)))

Function composition pipelines like this are common in functional languages, so how do programmers avoid this syntactic discomfort? In Scala, for example, compose is an infix function, which makes for fewer nested parentheses.

f.compose(g).compose(h).compose(k)

In Haskell, compose is spelled (.), which makes for very terse compositions:

f . g . h . k

You can in fact hack together an infix compose in TS. The idea is to wrap the underlying function in an object with a method which performs the composition. You could call that method compose, but I'm calling it _ because it's less noisy.

class Comp<T, U> {
    readonly apply : (x : T) => U

    constructor(apply : (x : T) => U) {
        this.apply = apply;
    }

    // note the extra type parameter, and that the intermediate type T is not visible in the output type
    _<V>(f : (x : V) => T) : Comp<V, U> {
        return new Comp(x => this.apply(f(x)))
    }
}

// example
const comp : (x : T) => R = new Comp(f)._(g)._(h)._(k).apply

Still not as neat as compose(f, g, h, k), but it's not too hideous, and it scales better than writing lots of overloads.

Puddling answered 30/10, 2018 at 14:35 Comment(5)
Not bad... This is something like what lodash does with array - wrapes them into chain object that has defined operations on them. Other than having special build-in type for this (like Flow has) or being able to extend all functions with compose function (like Scala) this is probably the best option.Pantaloons
Would it be possible to hack prototype of generic function type in such a way that would act like extension (in same meaning as e.g. in C#) so that we could do the same syntax like in Scala?Pantaloons
@Pantaloons I believe Function.prototype is read-only in JS, so sadly you can’t bolt methods on to Function.Puddling
@Pantaloons If you liked my answer, don't forget to award the bounty!Puddling
Sorry, too late. You got just half, what a waste... Nevertheless, I like it. Still my bounty helped this question to draw more attention, I think you will get more reputation from upvotes over time.Pantaloons
P
4

As of Typescript 4, variadic tuple types provide a way to compose a function, whos signature is inferred from an arbitrary number of input functions.

let compose = <T, V>(...args: readonly [
        (x: T) => any,          // 1. The first function type
        ...any[],               // 2. The middle function types
        (x: any) => V           // 3. The last function type
    ]): (x: V) => T =>          // The compose return type, aka the composed function signature
{
    return (input: V) => args.reduceRight((val, fn) => fn(val), input);
};

let pipe = <T, V>(...args: readonly [
        (x: T) => any,          // 1. The first function type
        ...any[],               // 2. The middle function types
        (x: any) => V           // 3. The last function type
    ]): (x: T) => V =>          // The pipe return type, aka the composed function signature
{
    return (input: T) => args.reduce((val, fn) => fn(val), input);
};

However there are still two drawbacks with this implementation:

  1. The compiler can not verify that the output of each function matches the input of the next
  2. The compiler complains when using the spread operator (but still successfully infers the composed signature)

e.g. The following will work at compile time and at runtime

let f = (x: number) => x * x;
let g = (x: number) => `1${x}`;
let h = (x: string) => ({x: Number(x)});


let foo = pipe(f, g, h);
let bar = compose(h, g, f);

console.log(foo(2)); // => { x: 14 }
console.log(bar(2)); // => { x: 14 }

While this will complain at runtime, but infer the signature correctly and run

let fns = [f, g, h];
let foo2 = pipe(...fns);

console.log(foo2(2)); // => { x: 14 }
Propylite answered 7/6, 2021 at 19:0 Comment(2)
This doesn't work because rest elements must be the last element in the array, so your ...any[] fails.Slaw
@Slaw As I mentioned off the top, it works as of Typescript 4, middle rest parameters were introduced. It is currently working as intended in my Typescript 4 project.Propylite
S
3

Here's an example of a strong typed compose function in TypeScript. It has the shortcoming of not checking each intermediate function type, but it is able to derive the arg and return type for the final composed function.

Compose Function

/** Helper type for single arg function */
type Func<A, B> = (a: A) => B;

/**
 * Compose 1 to n functions.
 * @param func first function
 * @param funcs additional functions
 */
export function compose<
  F1 extends Func<any, any>,
  FN extends Array<Func<any, any>>,
  R extends
    FN extends [] ? F1 :
    FN extends [Func<infer A, any>] ? (a: A) => ReturnType<F1> :
    FN extends [any, Func<infer A, any>] ? (a: A) => ReturnType<F1> :
    FN extends [any, any, Func<infer A, any>] ? (a: A) => ReturnType<F1> :
    FN extends [any, any, any, Func<infer A, any>] ? (a: A) => ReturnType<F1> :
    FN extends [any, any, any, any, Func<infer A, any>] ? (a: A) => ReturnType<F1> :
    Func<any, ReturnType<F1>> // Doubtful we'd ever want to pipe this many functions, but in the off chance someone does, we can still infer the return type
>(func: F1, ...funcs: FN): R {
  const allFuncs = [func, ...funcs];
  return function composed(raw: any) {
    return allFuncs.reduceRight((memo, func) => func(memo), raw);
  } as R
}

Example Usage:

// compiler is able to derive that input type is a Date from last function
// and that return type is string from the first
const c: Func<Date, string> = compose(
  (a: number) => String(a),
  (a: string) => a.length,
  (a: Date) => String(a)
);

const result: string = c(new Date());

How it Works We use a reduceRight on an array of functions to feed input through each function from last to first. For the return type of compose we are able to infer the argument type based on the last function argument type and the final return type from the return type of the first function.

Pipe Function

We can also create a strong typed pipe function that pipes data through first function then to next, etc.

/**
 * Creates a pipeline of functions.
 * @param func first function
 * @param funcs additional functions
 */
export function pipe<
  F1 extends Func<any, any>,
  FN extends Array<Func<any, any>>,
  R extends
    FN extends [] ? F1 :
    F1 extends Func<infer A1, any> ?
      FN extends [any] ? Func<A1, ReturnType<FN[0]>> :
      FN extends [any, any] ? Func<A1, ReturnType<FN[1]>> :
      FN extends [any, any, any] ? Func<A1, ReturnType<FN[2]>> :
      FN extends [any, any, any, any] ? Func<A1, ReturnType<FN[3]>> :
      FN extends [any, any, any, any, any] ? Func<A1, ReturnType<FN[4]>> :
      Func<A1, any> // Doubtful we'd ever want to pipe this many functions, but in the off chance someone does, we can infer the arg type but not the return type
    : never
>(func: F1, ...funcs: FN): R {
  const allFuncs = [func, ...funcs];
  return function piped(raw: any) {
    return allFuncs.reduce((memo, func) => func(memo), raw);
  } as R
}

Example Usage

// compile is able to infer arg type of number based on arg type of first function and 
// return type based on return type of last function
const c: Func<number, string> = pipe(
  (a: number) => String(a),
  (a: string) => Number('1' + a),
  (a: number) => String(a)
);

const result: string = c(4); // yields '14'
Sawhorse answered 8/12, 2018 at 20:59 Comment(0)
B
2

TypeScript 4's tuple type improvements can be leveraged to type pipe and compose functions, without defining a list of overrides.

The compiler will ensure that each function can be invoked with the following function as expected (each intermediate function is type checked). TypeScript Playground with the examples below.

type UnaryFunction = (x: any) => any

type Composable<Fn> = 
  Fn extends readonly [UnaryFunction] ? Fn :
  Fn extends readonly [any, ...infer Rest extends readonly UnaryFunction[]] ?
    readonly [(arg: ComposeReturn<Rest>) => any, ...Composable<Rest>, ] : never 
 
type ComposeReturn<Fns extends readonly UnaryFunction[]> = ReturnType<Fns[0]>

type ComposeParams<Fns> = Fns extends readonly [...any[], infer Last extends UnaryFunction] ?
  Parameters<Last>[0] : never

function compose<Fns extends readonly UnaryFunction[]>(...fns: Composable<Fns>) {
  return function(arg: ComposeParams<Fns>): ComposeReturn<Fns> {
    return fns.reduceRight((acc, cur) => cur(acc), arg) as ComposeReturn<Fns>;
  }
}

Example:

function add3(x: number): number {
  return x + 3
}

function uppercase(x: string): string {
  return x.toUpperCase();
}

function stringify(x: number): string {
  return x.toString();
}

const composed = compose(
  uppercase,
  stringify,
  add3,
);
console.log(composed(0));

One notable limitation is that TypeScript still can't infer generic function Parameter and Return types. As of TypeScript 4.7 you can help the compiler by using instantiation expressions.

function add3(x: number): number {
  return x + 3
}

function stringify(x: number): string {
  return x.toString();
}

function identity<T>(t: T): T {
  return t;
}

const composed = compose(
  stringify,
  // have to use Instantiation Expressions from TS 4.7 when using generics
  identity<string>,
  add3,
);
console.log(composed(0));
Bonus answered 22/7, 2022 at 15:19 Comment(0)
S
1

I found that it's not too hard to write a typed compose function now (TypeScript v4.1.5 and above, tested in TypeScript Playground). Here's an example. It can check each intermediate function type.

type Compose<F> =
    (F extends [infer F1, infer F2, ...infer RS] ?
        (RS extends [] ?
            (F1 extends (...args: infer P1) => infer R1 ?
                (F2 extends (...args: infer P2) => infer R2 ?
                    ([R1] extends P2 ?
                        (...args: P1) => R2 :
                        never) :
                    never) :
                never) :
            Compose<[Compose<[F1, F2]>, ...RS]>) :
        never);

type ComposeArgs<T> = Parameters<Compose<T>>;
type ComposeReturn<T> = ReturnType<Compose<T>>;

// I forget that composition is from right to left! 
type Reverse<T extends unknown[], RE extends unknown[] = []> = T extends [infer F, ...infer RS] ? Reverse<RS, [F, ...RE]> : RE;

function composeL2R<T extends Function[]>(...fns: T): (...args: ComposeArgs<T>) => ComposeReturn<T> {
    return (...args: ComposeArgs<T>): ComposeReturn<T> => fns.reduce((acc: unknown, cur: Function) => cur(acc), args);
}

function compose<T extends Function[]>(...fns: T): (...args: ComposeArgs<Reverse<T>>) => ComposeReturn<Reverse<T>> {
    return (...args: ComposeArgs<Reverse<T>>): ComposeReturn<Reverse<T>> => fns.reduceRight((acc: unknown, cur: Function) => cur(acc), args);
}

function fns(x: number): string { return `${x}0`; }
function fnn(x: number): number { return 2 * x; }
function fsn(x: string): number { return parseInt(x); }

let aNumber = compose(fsn, fns, fnn, fsn, fns, () => 1)();
let aNumberL2R = composeL2R(() => 1, fns, fsn, fnn, fns, fsn)();
let aNever = composeL2R(fnn, fsn, fns)(1);
let aNeverL2R = composeL2R(fnn, fsn, fns)(1);
Slang answered 6/5, 2022 at 15:3 Comment(0)
B
0

I did some digging around and found a wonderful recursive solution by '@cartersnook6139' in the comments section to Matt Pocock's video on a typed compose function. Here's the link to the Typescript Playground. Its magic!

declare const INVALID_COMPOSABLE_CHAIN: unique symbol;

type Comp = (arg: any) => any;

type IsValidChain<T extends ((arg: never) => any)[]> =
    T extends [infer $First extends Comp, infer $Second extends Comp, ...infer $Rest extends Comp[]]
        ? [ReturnType<$First>] extends [Parameters<$Second>[0]]
            ? IsValidChain<[$Second, ...$Rest]>
        : (T extends [any, ...infer $Rest] ? $Rest["length"] : never)
    : true;

type ReplaceFromBack<T extends unknown[], Offset extends number, Item, $Draft extends unknown[] = []> =
    $Draft["length"] extends Offset
        ? $Draft extends [any, ...infer $After]
            ? [...T, Item, ...$After]
        : never
    : T extends [...infer $Before, infer $Item]
        ? ReplaceFromBack<$Before, Offset, Item, [$Item, ...$Draft]>
    : never;

type asdf = ReplaceFromBack<[1, 2, 3, 4, 5, 6, 7, 8, 9], 3, "hey">;

function compose<Composables extends [Comp, ...Comp[]]>(
  ...composables:
        IsValidChain<Composables> extends (infer $Offset extends number)
            ? ReplaceFromBack<Composables, $Offset, "INVALID_COMPOSABLE">
        : Composables
) {
  return (
    firstData: Parameters<Composables[0]>[0]
  ): Composables extends [...any[], infer $Last extends (arg: never) => any]
    ? ReturnType<$Last>
    : never => {
    let data: any = firstData;
    for (const composable of composables) {
      data = (composable as any)(data);
    }
    return data;
  };
}

const addOne = (a: number): number => a + 1;
const numToString = (a: number): string => a.toString();
const stringToNum = (a: string): number => parseFloat(a);

namespace CorrectlyPassing {
  const v0 = compose(addOne, numToString, stringToNum); 
  //    ^?

  const v1 = compose(addOne, addOne, addOne, addOne, addOne, numToString);
  //    ^?

  const v2 = compose(numToString, stringToNum, addOne);
  //    ^?

  const v3 = compose(addOne, addOne, addOne);
  //    ^?
}

namespace CorrectlyFailing {
  // :o they actually show the error next to the incorrect one!
  compose(addOne, stringToNum);
  compose(numToString, addOne);
  compose(stringToNum, stringToNum);
  compose(addOne, addOne, addOne, addOne, stringToNum);
  compose(addOne, addOne, addOne, addOne, stringToNum, addOne);
}
Bryant answered 6/1, 2023 at 5:32 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.