10

I want to create a function chain, which would be an input of a pipe/flow/compose function.

Is this possible without the literal expansion of the types to selected depth, as is this usually handled? See lodash's flow.

I want to achieve typecheck of the data flow in the chain. - Argument of a function is result of the previous one - First argument is a template parameter - Last return is a template parameter

type Chain<In, Out, Tmp1 = any, Tmp2 = any> = [] | [(arg: In) => Out] | [(arg: In) => Tmp1, (i: Tmp1) => Tmp2, ...Chain<Tmp2, Out>];

The idea is in the draft.

This however produces tho following errors:

  1. Type alias 'Chain' circularly references itself. (understand why, don't know how to resole)
  2. A rest element type must be an array type. (probably spread is not available for generic tuples)
  3. Type 'Chain' is not generic. (don't even understand why this error is even here)

Is this definition of Chain possible in Typescript? If so, please enclose a snippet.

(Tested on latest tsc 3.1.6)

Praveen Kumar Purushothaman
  • 164,888
  • 24
  • 203
  • 252
  • Isn't this a "turtles all the way down" problem? i.e. it is impossible to resolve the recursive type. – Fenton Nov 06 '18 at 15:14

1 Answers1

28

Circular type aliases are not really supported except in certain cases. (UPDATE TS 4.1, these are more supported now, but I'm still inclined to represent flow() as operating on AsChain that verifies a particular array of functions instead of trying to come up with a Chain that matches all valid arrays of functions)

Instead of trying to represent the specific type you've written there in a TypeScript-friendly way, I think I'll back up and interpret your question as: how can we type a flow()-like function, which takes as its arguments a variable number of one-argument functions, where each one-argument-function return type is the argument type for the next one-argument-function, like a chain... and which returns a one-argument function representing the collapsed chain?

I've got something that I believe works, but it's quite complicated, using a lot of conditional types, tuple spreads, and mapped tuples. Here it is:

type Lookup<T, K extends keyof any, Else=never> = K extends keyof T ? T[K] : Else

type Tail<T extends any[]> = T extends [any, ...infer R] ? R : never;

type Func1 = (arg: any) => any;
type ArgType<F, Else=never> = F extends (arg: infer A) => any ? A : Else;
type AsChain<F extends [Func1, ...Func1[]], G extends Func1[]= Tail<F>> =
  { [K in keyof F]: (arg: ArgType<F[K]>) => ArgType<Lookup<G, K, any>, any> };

type Last<T extends any[]> = T extends [...infer F, infer L] ? L : never;
type LaxReturnType<F> = F extends (...args: any) => infer R ? R : never;

declare function flow<F extends [(arg: any) => any, ...Array<(arg: any) => any>]>(
  ...f: F & AsChain<F>
): (arg: ArgType<F[0]>) => LaxReturnType<Last<F>>;

Let's see if it works:

const stringToString = flow(
  (x: string) => x.length, 
  (y: number) => y + "!"
); // okay
const str = stringToString("hey"); // it's a string

const tooFewParams = flow(); // error

const badChain = flow(
  (x: number)=>"string", 
  (y: string)=>false, 
  (z: number)=>"oops"
); // error, boolean not assignable to number

Looks good to me.


I'm not sure if it's worth it to go through in painstaking detail about how the type definitions work, but I might as well explain how to use them:

  • Lookup<T, K, Else> tries to return T[K] if it can, otherwise it returns Else. So Lookup<{a: string}, "a", number> is string, and Lookup<{a: string}, "b", number> is number.

  • Tail<T> takes a tuple type T and returns a tuple with the first element removed. So Tail<["a","b","c"]> is ["b","c"].

  • Func1 is just the type of a one-argument function.

  • ArgType<F, Else> returns the argument type of F if it's a one-argument function, and Else otherwise. So ArgType<(x: string)=>number, boolean> is string, and ArgType<123, boolean> is boolean.

  • AsChain<F> takes a tuple of one-argument functions and tries to turn it into a chain, by replacing the return type of each function in F with the argument type of the next function (and using any for the last one). If AsChain<F> is compatible with F, everything's good. If AsChain<F> is incompatible with F, then F is not a good chain. So, AsChain<[(x: string)=>number, (y:number)=>boolean]> is [(x: string)=>number, (y: number)=>any], which is good. But AsChain<[(x: string)=>number, (y: string)=>boolean]> is [(x: string)=>string, (y: string)=>any], which is not good.

  • Last<T> takes a tuple and returns the last element, which we need to represent the return type of flow(). Last<["a","b","c"]> is "c".

  • Finally, LaxReturnType<F> is just like ReturnType<F> but without a constraint on F.


Okay, hope that helps; good luck!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 2
    What you did here is pretty impressive. It works indeed. The only minor setback is that the error seems to be always on the first function, no matter where the chain breaks (though message is correct), which inherently seems to be the problem of the solution. You are validating the chain in AsChain, not defining the type. The main issue is obviously the complexity of the notation. Why are recursive types not supported? Are they planned to be supported in following versions? The draft I provided, if supported, is brief, conscious, maintainable and easy to write. – Jaroslav Šmolík Nov 13 '18 at 14:05
  • "Are they planned to be supported"? Read the "Recursive Types" design notes section here: https://github.com/Microsoft/TypeScript/issues/27102 – jcalz Nov 13 '18 at 14:27
  • as for error always on the first function, yeah I noticed that. I'm not sure that a recursive type would fare better on that front; it seems to be a bug in IntelliSense. Maybe I'll report it. – jcalz Nov 13 '18 at 15:09
  • Okay I [reported the error message issue](https://github.com/Microsoft/TypeScript/issues/28505) and it's apparently a bug. Maybe it'll be fixed in TS3.3? – jcalz Nov 13 '18 at 20:19
  • Ok, I went through the code, and I appriciate it even more. But I have found another issue: See this snippet (https://pastebin.com/FqxUXCit). The arguments are correctly validated, if you state all the types explicitly. However if you omit the types, the type from the previous function is not inferred. Again, I think this is the problem inherited from your "validation" solution, instead of "constructive" solution, via recursion (which is alas not supported in TS). Notice that all functions are `(_: any) => any`, types cannot be inferred this way. The result is only validated through `AsChain`. – Jaroslav Šmolík Nov 16 '18 at 07:37
  • Also (for further readers), `Tail` can be simplifed to avoid confusion with function types `type Tail = T extends [any, ...Array] ? U[] : never;`. And `K extends keyof any` is redundant in `Lookup` – Jaroslav Šmolík Nov 16 '18 at 08:02
  • You cannot simplify `Tail` that way without losing the ordering. `Tail<[1,2,3,4]>` should be `[2,3,4]`, not `Array<2 | 3 | 4>`. Moreover, `Tail>` should be `[3,4]`, not `never`. – jcalz Nov 16 '18 at 16:27
  • I don't see how `K extends keyof any` is redundant; it's a constraint on the type of thing you can pass as `K` when calling `Lookup`. You could remove the constraint if you like, but it means something slightly different... `Lookup<{}, {}>` would be a compile error in my version, and `never` in yours. – jcalz Nov 16 '18 at 16:29
  • As for the issue with inferring types from previous functions, yeah, it's possible that *if* TypeScript supported recursive types, it would solve that problem, but there's no way to know right now. Possibly there is a way to get your desired behavior without recursive types; the easiest would be to pick some maximum chain length and hardcode a non-recursive type. See [this answer](https://stackoverflow.com/questions/53293200/typescript-get-deeply-nested-property-value-using-array/53305221#53305221) for such a solution to a different problem. – jcalz Nov 16 '18 at 16:33
  • You are right with the `Tail`, thank you for clarification. Same goes for `Lookup`, I did not realize `keyof any` ~ `string | number | symbol`. – Jaroslav Šmolík Dec 07 '18 at 14:02
  • doesn't work with generic function arguments unfortunately, as `ArgType` cannot retain and propagate the type parameter (inferred to `unknown`). E.g. `const asList = (t: T) => [t] as [T]` in `stringToString`. – ford04 Mar 05 '20 at 10:17
  • Wow this is cool! Do you know why this breaks now on TS 4.2.3? `Type 'Last' does not satisfy the constraint '(...args: any) => any'.` – joshuaaron Mar 11 '21 at 06:03
  • @joshuaaron hmm, not sure, but I've fixed it by relaxing the constraint on `ReturnType` above. – jcalz Mar 11 '21 at 13:59
  • @jcalz stupid question but with this just being a declaration function, how would the flow implementation look for this so it could be used in a ts/tsx file? – joshuaaron Mar 11 '21 at 19:25
  • 1
    Maybe like [this](https://tsplay.dev/N7bPDw)? An explanation is probably outside the scope of the question and not suitable for comments. – jcalz Mar 11 '21 at 20:09
  • 3
    For a different question (closed as a duplicate of this one :-) I arrived upon this recursive solution: https://tsplay.dev/wQ5r1w The error messages are a bit more accurate, and the code is relatively easy to follow. If anyone is still interested in this topic, I can add it as a second answer to the question. – Oblosys Mar 31 '22 at 21:00