2

If one has an array of functions, where the output from one is the input into the next function i.e. inputs and outputs types must be aligned for each pair, but may differ across pairs. How can one enable to Typescript compiler to understand the typing:

type A = () => string
type B = (str: string)=> number
type C = (num: number)=> [number,number]
const a:A = ()=>'1'
const b:B = (str)=>parseInt(str)
const c:C = (num)=>[num,num]

//typescript is fine with this
console.log(`result: ${c(b(a()))}`)

//but not what follows, even though it's similar functionality
//this is not dynamic typing as the order in which functions
//are dealt with is known and fixed at compile time

const arrayOfFunctions: [A,B,C] = [a,b,c]

let prevResult: any
arrayOfFunctions.forEach(fn=>{
  // nasty hack that breaks typing by casting everything to any
  const hackedFn : (res?:any)=>any = fn as (res?:any)=>any
  if(prevResult) prevResult = hackedFn(prevResult)
  else prevResult = hackedFn()
})
console.log(`prevResult: ${prevResult}`)

How can one execute arrayOfFunctions without breaking the typing?

code

TrevTheDev
  • 2,616
  • 2
  • 18
  • 36
  • 1
    I don't see any error in your code. What is the problem? – Ricky Mo Jan 27 '22 at 04:58
  • @Ricky Mo: the nasty hack breaks all typing as it converts everything to `any` - i.e. all type checking is lost. If you remove the hack, it errors. – TrevTheDev Jan 27 '22 at 05:03
  • So the thing you want is for TypeScript to be able to tell you if `arrayOfFunctions` is a proper chain of inputs and outputs ? because otherwise, you can just say `arrayOfFunctions.forEach((fn: (res?: any) => any)=>{ /* call fn itself 8? })` – Samathingamajig Jan 27 '22 at 05:05
  • What is your goal? You only mentioned the "nasty hack". What is the "non-hacky" thing you want to write? – Ricky Mo Jan 27 '22 at 05:06
  • The compiler only compiles, it does not run your code. There is no way for the compiler to figure out the exact type of `arrayOffunctions[i]` for variable `i`. `A|B|C` is the best the compiler can tell. – Ricky Mo Jan 27 '22 at 05:14
  • @Ricky Mo, yup, that seems to be the case. Although as the compiler can know `arrayOfFunction[1] = A`, it should be able to return `A` and not `A|B|C` My goal is to run the array with type safety, i.e. without having to resort to casting to `any` which looses type safety. – TrevTheDev Jan 27 '22 at 06:28
  • I don't think an `Array` will be an appropriate structure to hold such a chain(if we are unwilling to keep an `any` type, and also at the cost of type-safety). – Nalin Ranjan Jan 27 '22 at 06:58
  • `arrayOfFunction[1]` can return `A` at compile time, but not `arrayOfFunction[i]` with `i` just happen to equal to `1` at runtime. – Ricky Mo Jan 27 '22 at 07:16
  • 1
    The best I can think of is essentially [this code](https://tsplay.dev/NnQQkw) which only validates the chain types from the outside, but inside is still laxly typed. So type checking the *execution* is essentially impossible for arbitrary chains, while type checking the *chain itself* is possible. See [this question](https://stackoverflow.com/questions/53173203/typescript-recursive-function-composition) and its answer for more information. Does this address your question or am I missing something? – jcalz Jan 27 '22 at 16:32
  • @jcalz thank you - that looks like it may be the solution! – TrevTheDev Jan 28 '22 at 00:21
  • So do you want to see this written up as its own answer, or does the answer in the linked question suffice for you? – jcalz Jan 28 '22 at 01:30
  • @jcalz the answer in the code you supplied is fine for me - I'll leave it to you to decide if you want to answer it and I'll then accept your answer. – TrevTheDev Jan 28 '22 at 02:03
  • 1
    [This version](https://tsplay.dev/WoDGgw) might be a little less crazy; does it still work for your use cases? – jcalz Jan 28 '22 at 02:38
  • @jcalz I've spent the entire day trying to understand your first solution - and I think I've just done so - and it's awesome. And now you present an even simpler solution. Well I know what I'll be doing tomorrow! Thank you! – TrevTheDev Jan 28 '22 at 09:37
  • 1
    Oops, sorry about that. Well let me know if you want me to write up the new version. – jcalz Jan 28 '22 at 14:40
  • @jcalz: I've gone with your second solution as it's simpler to use and understand. If you provide that as the solution I'll accept it. Thank you for both solution - I learnt a lot. – TrevTheDev Jan 29 '22 at 06:29

2 Answers2

5

TypeScript doesn't have a way to express the higher-order types needed to give reduce() or forEach() call signatures that allow you to chain functions together like this safely. Neither can you do this with a for loop. Unless you actually unroll the loop and call arr[2](arr[1](arr[0]())), you cannot implement this sort of thing hand have the compiler catch you if you make a mistake. The implementation will therefore involve some amount of assertions where you just tell the compiler that what you're doing is okay.

That being said, if you want to have a reusable callChain() function which takes an array of "properly chained functions" as input, you can give that function a call signature which checks the "properness" (propriety?) of the array and which returns the right type. The implementation will be somewhat unsafe, but you only need to write that implementation once, and hopefully call it more than once.

Here's one possible way to do that:

type TupleToArgs<T extends any[]> =
  Extract<[[], ...{ [I in keyof T]: [arg: T[I]] }], Record<keyof T, any>>;
type TupleToChain<T extends any[]> =
  { [I in keyof T]: (...args: Extract<TupleToArgs<T>[I], any[]>) => T[I] };
type Last<T extends any[]> =
  T extends [...infer _, infer L] ? L : never;

function callChain<T extends any[]>(fns: [...TupleToChain<T>]): Last<T>
function callChain(funcs: ((...args: any) => any)[]) {
  const [f0, ...fs] = funcs;
  return fs.reduce((a, f) => f(a), f0());
}

Before I go into how it works, let's make sure that it works, using your examples:

const result = callChain(arrayOfFunctions)
// const result: [number, number]

console.log(result) // [1, 1]

callChain([a, b, b]) // error!
// ------------> ~ 
// Type 'B' is not assignable to type '(arg: number) => number'.
// Types of parameters 'str' and 'arg' are incompatible.
// Type 'number' is not assignable to type 'string'.

That's what you want. The proper chain is accepted and the return type is [number, number]. And the improper chain results in a compiler error where the first bad element in the chain is highlighted as taking the wrong input type.

So, that's good.


So, let's explain how it works. First, the function itself:

function callChain<T extends any[]>(fns: [...TupleToChain<T>]): Last<T>
function callChain(funcs: ((...args: any) => any)[]) {
  const [f0, ...fs] = funcs;
  return fs.reduce((a, f) => f(a), f0());
}

The call signature is generic in T, a tuple type corresponding to the return type of each function in the chain. So if T is [string, number, boolean], that means the first function in the chain returns a string, the second returns a number, and the third returns a boolean. The TupleToChain<T> type should therefore turn such a tuple of types into a tuple of functions of the right type. The return type of callChain is Last<T>, which should hopefully be just the last element of the T tuple.

The function is written as a single-call-signature overloaded function. Overloaded function implementations are intentionally checked more loosely by TypeScript, (see ms/TS#13235 for why this is), which makes it easier to split apart the function into the strongly-typed call side and the weakly-typed implementation. The implementation uses a lot of the any type to sidestep compiler errors. As I said at the outset, the implementation can't really be properly type checked, so I'm using any to stop the compiler from even trying.

At runtime we're splitting the array into the first element (which takes no input) and the rest of the array (which all take an input), and then using the reduce() array method to do the actual chaining.


Let's look at the types, now. The easy one is Last:

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

That's just using variadic tuple types and conditional type inference to pluck the last element from a tuple.

Now for TupleToChain:

type TupleToChain<T extends any[]> =
  { [I in keyof T]: (...args: Extract<TupleToArgs<T>[I], any[]>) => T[I] };

This is just a mapped tuple type which takes the bare types in T and produces a new tuple of functions. For each type at index I in the input tuple T, the function in the output tuple will return the type T[I], and it will take as a list of arguments something called TupleToArgs<T>[I]. So TupleToArgs<T>[I] had better give us the list of arguments required for the Ith function in the chain. If I is 0, then we want an empty list like [], whereas if I is J+1 for some J, then we want a list with the single element [T[J]]. (This will be an array type no matter what, but the compiler can't see it here, since TS4.7, so I wrapped it with Extract<..., any[]> to convince the compiler of this fact.)

To be painfully clear, if T is [string, number, boolean], we want TupleToArgs<T>[0] to be [] in order to form ()=>string; we want TupleToArgs<T>[1] to be [string] in order to form (arg: string)=>number; and we want TupleToArgs<T>[2] to be [number] in order to form (arg: number)=>boolean.

So here's TupleToArgs:

type TupleToArgs<T extends any[]> =
  Extract<[[], ...{ [I in keyof T]: [arg: T[I]] }], Record<keyof T, any>>;

This is another mapped tuple type where we wrap each element of T in a one-tuple (so [string, number, boolean] becomes [[string],[number],[boolean]]) and then we prepend an empty tuple to it (so we get [[],[string],[number],[boolean]]). This indeed works exactly how we want TupleToArgs to work (the extra [boolean] at the end doesn't hurt anything)...

...but we wrap it in an extra bit Extract<..., Record<keyof T, any>> using the Extract<T, U> utility type. This doesn't actually change the type, since the returned tuple will indeed be assignable to Record<keyof T, any>. But it helps the compiler see that for I in keyof T, the type TupleToArgs<T>[I] is valid. Otherwise the compiler gets confused and is worried that maybe I is not a key of TupleToArgs<T>.


And that's basically it. It's kind of convoluted, but not the worst thing I've ever written. I'm sure there are edge cases where it doesn't work. Certainly if your array of functions isn't defined as a tuple type you'll have a bad time. For a single chain like [a, b, c], there's really nothing better than just c(b(a())). But if you find yourself needing to write such chains many times, something like callChain() be worth its complexity. Exactly where the line where that happens is subjective; in my opinion you'd have to be doing these tuple-typed function chains quite often in your code base for it to be worthwhile. But whether or not you actually use this, it's interesting to consider how close the language can get you to representing such operations in the type system.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you for this explanation. Using your example, I had worked out most of this. However the `Extract` had me confused and I was going to ask you about it then you even explained that! – TrevTheDev Jan 31 '22 at 01:42
  • The playground no longer compiles starting with 4.7. `TupleToArgs` has an error of `A rest parameter must be of an array type.` here: `(...args: TupleToArgs[I])`. – Eric Haynes Oct 24 '22 at 01:45
  • You're fast! I've really wanted a clean solution to this for a long time. It's not an uncommon functional paradigm to want to compose a group of functions into a chain. It's essentially just the shell's `|` operator, and yet it's really painful to achieve without a static list. – Eric Haynes Oct 24 '22 at 04:37
1

Here is some code to build a processor with tuple types you provided and do something with type checking.

This is strictly worse than arr[2](arr[1](arr[0]())) in terms of scalability, see jcalz's comment.

type A = () => string
type B = (str: string) => number
type C = (num: number) => [number, number]
const a: A = () => '1'
const b: B = (str) => parseInt(str)
const c: C = (num) => [num, num]

const arrayOfFunctions: [A, B, C] = [a, b, c]

function builder<
  T extends Array<any>,

  K extends (keyof T) & `${number}`
  = (keyof T) & `${number}`,

  U extends { [index in K & `${number}`]: T[index] & { (...args: any): any } }
  = { [index in K]: (T[index] extends { (...args: any): any } ? T[index] : never) }
>(array: T) {
  return function (step: (opt: {
    [Index in keyof U]: {
      index: Index,
      value: ReturnType<U[Index]>
      next: (opt: ReturnType<U[Index]>) => void
    }
  }[keyof U]) => void) {
    let prevValue: ReturnType<U[keyof U]> | undefined = undefined
    for (let index in array) {

      if (index === '0') {
        prevValue = array[index]()
      }
      else {
        prevValue = array[index](prevValue)
      }
      let nextValue: ReturnType<U[keyof U]> | undefined = undefined
      let nextCalled = false
      step({
        index: index as any,
        value: prevValue as any,
        next: function (v: any) {
          nextValue = v
          nextCalled = true
        } as any
      })
      if (nextCalled === false) {
        throw 'next() not called.'
      }
      prevValue = nextValue
    }
    return prevValue
  }
}

let ret = builder(arrayOfFunctions)(function (opt) {
  if (opt.index == '0') {
    console.log(opt.index, opt.value)
    opt.next(opt.value)
  }
  if (opt.index == '1') {
    console.log(opt.index, opt.value)
    opt.next(opt.value)
  }
  if (opt.index == '2') {
    console.log(opt.index, opt.value)
    opt.next(opt.value)
  }
})

console.log(ret)



Playground Link

arrayOfFunctions is tuple type, we can build a unity with Discriminated unions.

lei li
  • 1,244
  • 1
  • 12
  • 35
  • That is amazing! It'll take me sometime to digest and understand it. Thank you for sharing! – TrevTheDev Jan 27 '22 at 08:31
  • @TrevTheDev I updated my code to fix value type and make the types stronger. That's quite terrible work my friend. – lei li Jan 27 '22 at 14:37
  • Ehhh this doesn't catch if the chain is valid, and then requires that the actual meat of the function writes one call per function, so it scales linearly with the length of the array (so strictly worse than `arr[2](arr[1](arr[0]()));` in terms of scalability). And your playground link has `opt.value` being of type `never`, and the both the link and plaintext code have `ret` being of type `undefined`. I'm confused about what's going on here. – jcalz Jan 27 '22 at 16:18
  • @jcalz I fixed my code. Actually I fixed it yesterday and post the broken code : ). I agree with you. The code did less than `arr[2](arr[1](arr[0]()))`. – lei li Jan 28 '22 at 01:35