8

The example of the release notes of TypeScript 4 shows how to use variadic tuple types to avoid several overload definitions. I guess it should be possible to type this pipe function for an arbitrary amount of arguments

type F<P, R> = (p: P) => R

type Pipe2<T1, T2, R> = [F<T1, T2>, F<T2, R>]
type Pipe3<T1, T2, T3, R> = [F<T1, T2>, ...Pipe2<T2, T3, R>]
type Pipe4<T1, T2, T3, T4, R> = [F<T1, T2>, ...Pipe3<T2, T3, T4, R>]

function pipe<T1, R>(f1: F<T1, R>): F<T1, R>
function pipe<T1, T2, R>(...fns: Pipe2<T1, T2, R>): F<T1, R>
function pipe<T1, T2, T3, R>(...fns: Pipe3<T1, T2, T3, R>): F<T1, R>
function pipe<T1, T2, T3, T4, R>(...fns: Pipe4<T1, T2, T3, T4, R>): F<T1, R>
function pipe(...fns) {
  return x => fns.reduce((res, f) => f(res), x)
}

A basic start could be

function pipe<Fns>(...fns: PipeArgs<Fns>): PipeReturn<Fns>
function pipe(...fns) {
  return x => fns.reduce((res, f) => f(res), x)
}

where the definitions of helper types PipeArgs<Fns> and PipeReturn<Fns> are yet missing. How can they be defined or is there another approach?


Edit: I'm not so confident anymore that it can be done, yet (TypeScript 4.1.2). The main issue are the rest parameters. The (tuple) type of the rest parameters fns of pipe has to be inferred, but a specific (circular?) structure has to be ensured. Here is my current approach (with a working PipeReturn<Fns>)

type AssertReturn<E, _A extends E, R> = R

type Return<F> =
    F extends ((...args: any[]) => infer R)
        ? R
        : never

type Length<L extends any[]> = L['length']

type Tail<L extends any[]> =
    L extends readonly [any, ...infer LTail]
        ? LTail
        : L

type Last<L extends any[]> = L[Length<Tail<L>>]

type F<P, R> = (p: P) => R

type PipeArgs<Fns> =
    Fns extends readonly [F<infer X, infer Y>, ...infer T]
        ? T extends readonly [F<any, any>, ...any]
            ? [F<X, Y>, ...PipeArgs<T>]
            : T extends readonly []
                ? [F<X, Y>]
                : never
        : never

type PipeReturn<Fns extends F<any, any>[]> =
    Fns extends readonly [F<infer I, infer O>, ...infer T]
        ? T extends readonly [F<any, any>, ...any]
            ? F<I, Return<Last<T>>>
            : F<I, O>
        : never

Before I show the signatures of pipe that I tried, but are not working, I show some tests/examples and their expected behaviour

declare const a: any

const ae_pass_1: number = a as AssertReturn<number, number, number>
const ae_pass_2: string = a as AssertReturn<number, number, string>
// Expected compile error:
//   Type 'string' does not satisfy the constraint 'number'.
//                                                    V
const ae_pass_3: string = a as AssertReturn<number, string, string>
// Expected compile error:
//   Type 'string' is not assignable to type 'number'.
//            V
const ae_fail_returnType: number = a as AssertReturn<number, number, string>


declare const pr1: PipeReturn<[F<number, string>]>
const pr1_pass: F<number, string> = pr1
// Expected compile error:
//   Type 'F<number, string>' is not assignable to type 'F<number, boolean>'.
//       V
const pr1_fail: F<number, boolean> = pr1

declare const pr2: PipeReturn<[F<number, string>, F<string, boolean>]>
const pr2_pass: F<number, boolean> = pr2
// Expected compile error:
//   Type 'F<number, boolean>' is not assignable to type 'F<number, string>'.
//       V
const pr2_fail: F<number, string> = pr2


declare const pa1: PipeArgs<[F<number, string>]>
const pa1_pass: [F<number, string>] = pa1
// Expected compile error:
//   Type '[F<number, string>]' is not assignable to type '[F<number, boolean>]'.
//       V
const pa1_fail: [F<number, boolean>] = pa1

declare const pa2: PipeArgs<[F<number, string>, F<string, boolean>]>
const pa2_pass: [F<number, string>, F<string, boolean>] = pa2
// Expected compile error:
//   Type '[F<number, string>, F<string, boolean>]' is not assignable to type '[F<number, string>, F<number, boolean>]'.
//       V
const pa2_fail: [F<number, string>, F<number, boolean>] = pa2


declare const numberToString: F<number, string>
declare const stringToBoolean: F<string, boolean>

// no compile error expected
const pipe_pass: F<number, boolean> =
    pipe<[F<number, string>, F<string, boolean>]>(numberToString, stringToBoolean)
// no compile error expected
const pipe_pass_argTypeInfered: F<number, boolean> =
    pipe(numberToString, stringToBoolean)
// assignment should cause compile error since second function should expect
// string as parameter, but actually expects number:
//   Type 'F<number, boolean>' is not assignable to type 'F<number, string>'.
//             V
const pipe_fail_returnType: F<number, string> =
    pipe(numberToString, stringToBoolean)
// pipe call should cause compile error since second function should expect
// string as parameter, but actually expects number
// Expected compile error should be something like:
//   Type 'F<number, string>' is not assignable to type 'F<string, T>'.
//                                                                   V
const pipe_fail_args: F<number, string> = pipe(numberToString, numberToString)

In the following the different pipe signatures and what test/example fails (is not as expected)

function pipe<Fns extends F<any, any>[]>(...fns: PipeArgs<Fns>): PipeReturn<Fns>
const pipe_pass_argTypeInfered: F<number, boolean> =
//  but
//    Argument of type 'F<number, string>' is not assignable to parameter of type 'never'.(2345)
//    The call would have succeeded against this implementation, but implementation signatures of overloads are not externally visible.
//             V
    pipe(numberToString, stringToBoolean)

Add Fns & compared to previous approach

function pipe<Fns extends F<any, any>[]>(...fns: Fns & PipeArgs<Fns>): PipeReturn<Fns>

fixes previous error, but does not cause this expected error

// pipe call should cause compile error since second function should expect
// string as parameter, but actually expects number
// Expected compile error should be something like:
//   Type 'F<number, string>' is not assignable to type 'F<string, T>'.
//                                                                   V
const pipe_fail_args: F<number, string> = pipe(numberToString, numberToString)

Another idea is to assert in the return type that Fns has the expected structure, but this definition has an error itself

//   Type 'Fns' does not satisfy the constraint 'PipeArgs<Fns>'.
//   Type 'F<any, any>[]' is not assignable to type 'PipeArgs<Fns>'.
//                                                                                  V
function pipe<Fns extends F<any, any>[]>(...fns: Fns): AssertReturn<PipeArgs<Fns>, Fns, PipeReturn<Fns>>

Edit 2: By the way, the library ts-toolbelt has several type definitions to type your pipe function up to 10 arguments (not an arbitrary amount of arguments).

maiermic
  • 4,764
  • 6
  • 38
  • 77
  • Did you manage to get what you want ? I succeed in getting the returnType of the last step of the pipe, but struggle with passing the returnType of a previous function to the next one. – Bonlou Jul 21 '21 at 16:23
  • 1
    Take a look on my article https://catchts.com/FP-style#compose – captain-yossarian from Ukraine Jul 21 '21 at 17:00
  • @captain-yossarian Do you think that `pipe` can be implemented in a similar way to your `compose` implementation (i.e. with reversed parameter order)? – maiermic Jul 24 '21 at 18:29
  • @Bonlou No, according to [this](https://github.com/microsoft/TypeScript/pull/39094#issuecomment-647042984) comment of Anders Hejlsberg (lead architect of TypeScript) it is not possible without the introduction of new concepts in type inference. – maiermic Jul 24 '21 at 18:40
  • Yes, I thinj it is possible. Compose function is just a reverse of pipe, is not it? – captain-yossarian from Ukraine Jul 24 '21 at 18:53

2 Answers2

3

Seems, Anders comment is obsolete.

type Foo = typeof foo
type Bar = typeof bar
type Baz = typeof baz

type Fn = (a: any) => any

type Head<T extends any[]> = T extends [infer H, ...infer _] ? H : never

type Last<T extends any[]> = T extends [infer _]
  ? never
  : T extends [...infer _, infer Tl]
  ? Tl
  : never

type Allowed<T extends Fn[], Cache extends Fn[] = []> = T extends []
  ? Cache
  : T extends [infer Lst]
  ? Lst extends Fn
    ? Allowed<[], [...Cache, Lst]>
    : never
  : T extends [infer Fst, ...infer Lst]
  ? Fst extends Fn
    ? Lst extends Fn[]
      ? Head<Lst> extends Fn
        ? ReturnType<Fst> extends Head<Parameters<Head<Lst>>>
          ? Allowed<Lst, [...Cache, Fst]>
          : never
        : never
      : never
    : never
  : never

type FirstParameterOf<T extends Fn[]> = Head<T> extends Fn
  ? Head<Parameters<Head<T>>>
  : never

type Return<T extends Fn[]> = Last<T> extends Fn ? ReturnType<Last<T>> : never

function pipe<
  T extends Fn,
  Fns extends T[],
  Allow extends {
    0: [never]
    1: [FirstParameterOf<Fns>]
  }[Allowed<Fns> extends never ? 0 : 1]
>(...args: [...Fns]): (...data: Allow) => Return<Fns>

function pipe<T extends Fn, Fns extends T[], Allow extends unknown[]>(
  ...args: [...Fns]
) {
  return (...data: Allow) => args.reduce((acc, elem) => elem(acc), data)
}

const foo = (arg: string) => [1, 2, 3]
const baz = (arg: number[]) => 42

const bar = (arg: number) => ['str']

const check = pipe(foo, baz, bar)('hello') // string[]
const check3 = pipe(baz, bar)([2]) // string[]
const check2 = pipe(baz, bar)('hello') // expected error

Playground

There is also a nice fnts library which uses Compose type with better error handling

  • 1
    Thanks, looks good. You should name the method `pipe` instead of `compose`. Usually, `pipe` calls the passed functions from left to right and `compose` calls the passed functions from right to left. – maiermic Jul 24 '21 at 20:58
  • 1
  • 1
    FYI, I've formatted your code using [Prettier](https://prettier.io/). Especially, the ternary expression of `Allowed` was hard to read. – maiermic Jul 24 '21 at 21:07
  • @maiermic thanks, this is because I'm using only TS playground for stackoverflow answers. You can also wrap conditional statements into parentheses – captain-yossarian from Ukraine Jul 24 '21 at 21:12
  • 1
    Here https://stackoverflow.com/questions/65057205/typescript-reduce-an-array-of-function/67760188#67760188 you can find how to handle anonymous functions. It is similar but int the same time, a bit chalenging – captain-yossarian from Ukraine Jul 24 '21 at 21:18
  • 1
    could anyone point me towards some sort of tutorial explaining the basics of this sort of type programming in TypeScript? I'having a very hard time reading such code as in the response... – djfm Dec 27 '21 at 01:47
  • @djfm my bad. I did not provide an explanation. Please see my article https://catchts.com/FP-style#compose . It should give you more context. You can check other articles in my blog. Also, all complicated types ib ts are usualy chained / nested conditional types. Google: ts conditional types, distributive conditional types and recursive types. Also find @ jcalz and @ Titian Dragomir Cernicova users on stackoverflow. They provide excelent explanation in their answers – captain-yossarian from Ukraine Dec 27 '21 at 10:19
  • 1
    thanks a lot, that will be my reading for tonight :) I understand the complex types more or less but I would be unable to come up with them and I feel I'm missing some ground principle, it all looks very hackish to me. Your blog looks pretty cool. – djfm Dec 27 '21 at 22:14
  • @djfm thank you. Also try to answer on SO questions with typescript tag. If you dont know how to answer- follow the question – captain-yossarian from Ukraine Dec 27 '21 at 22:26
  • I believe the reason for typing them all out as parameters is because they are assigned a value with which you would also be able to make the type of the parameters inferable. The way the error message will more likely point out the offending function. Even though this is a good solution, typing them all out has some benefits – Nico Jan 04 '22 at 11:39
  • Great answer! I am just wondering why you didn't define `Last` to be `T extends [...infer _, infer Tl] ? Tl : never`. It seems like with your definition `Last<[number]> = never`. Is there any good reason for that? – Tomasz Lenarcik Mar 26 '22 at 06:24
  • 1
    @TomaszLenarcik sorry, I don't remember. Since 24 February, I have no time. Try to test your solution against mine. I'd willing to bet that your `Last` is better – captain-yossarian from Ukraine Mar 26 '22 at 09:06
  • @captain-yossarian No worries, I tested it and resolved the problem locally. Sorry for bothering you, I wasn't aware of your current situation. I hope the war will end soon. – Tomasz Lenarcik Mar 27 '22 at 06:28
  • Mixing the example and the implementation up makes this difficult to parse. – mxcl May 14 '22 at 12:40
1

I had to solve a very similar problem today. I think I came up with a decently simple-feeling solution.

// Gets last type in a tuple of types
type Last<T extends readonly any[]> = T extends readonly [...any[], infer F]
    ? F
    : never;

// Loose*<T> gives never if T isn't valid, rather than constraining T
type LooseParameters<T> = T extends (...args: infer Args) => any ? Args : never;
type LooseReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type LooseSetReturnType<NewType, T> = T extends (...args: infer Args) => any
    ? (...args: Args) => NewType
    : never;

/**
 * Gives T if T is a valid pipeline.
 *
 * Tries to give what T should be if not. Example:
 *
 * Pipeline<[(f: any) => number, (f: number[]) => any]> =
 *    [(f: any) => number[], (f: number[]) => any]
 *
 * Notice that only the return type of the first function has changed.
 */
type LoosePipeline<T extends readonly any[]> = T extends readonly [
    infer A,
    infer B,
    ...infer Rest
]
    ? readonly [
          LooseSetReturnType<LooseParameters<B>[0], A>,
          ...LoosePipeline<readonly [B, ...Rest]>
      ]
    : readonly [...T];

function pipe<T extends readonly ((arg: any, ...args: undefined[]) => any)[]>(
    ...pipeline: LoosePipeline<T>
) {
    return (arg: Parameters<T[0]>[0]): LooseReturnType<Last<T>> =>
        pipeline.reduce<any>((acc, elem) => elem(acc), arg);
}

const foo = (arg: string) => [arg.length];
const baz = (arg: number[]) => Math.max(...arg);
const bar = (arg: number) => [arg.toString()];

const check: string[] = pipe(foo, baz, bar)("hello");
const check2: string[] = pipe(baz, bar)([2]);

// @ts-expect-error
const check3 = pipe(baz, bar)("hello");
johncs
  • 163
  • 2
  • 8