简体   繁体   中英

Chaining function types in typescript

If one has an array of functions, where the output from one is the input into the next function ie 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

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

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>>(array: T) {
  return function (step: (opt: {
    [Index in keyof T]: {
      index: Index,
      value: T[Index]
      next: (opt: T[Index]) => void
    }
  }[number]) => void) {
    let prevValue: T[number] | undefined = undefined
    for (let ind = 0; ind < array.length; ind++) {
      if (ind === 0) {
        prevValue = array[ind]()
      }
      else {
        prevValue = array[ind](prevValue)
      }
      let nextValue: T[number] | undefined = undefined
      let nextCalled = false
      step({
        index: ind,
        value: prevValue,
        next: function (v) {
          nextValue = v
          nextCalled = true
        }
      })
      if (nextCalled === false) {
        throw 'next() not called.'
      }
      prevValue = nextValue
    }
    return prevValue
  }
}

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

Playground Link

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: TupleToArgs<T>[I]) => 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: TupleToArgs<T>[I]) => 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 I th 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]] .

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

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM