Jaroslav Šmolík
Jaroslav Šmolík

Reputation: 337

Funciton composition in Typescript without overloads

Is it possible to define a Typescript type for function composition (see flow or pipe) for any number of arguments (functions to compose) without overwrites but with ability to hint types?

Without the type inference, there is a marvelous answer in my previous question.

Alas this solution only validates the chain and reports errors when defining types explicitly:

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

But all arguments are

flow(
  (x: number)=>"string",
  (y: string)=>false,
  z => {/*z is any, but should be inferred as boolean*/}
);

This inference works in lodash and ramda types, but its definition is using lengthly unmaintainable overloads, as stated in my previous questtion.

Is there a way to avoid overwrites and not lose the type inference?

Upvotes: 2

Views: 407

Answers (3)

Edward Kotarski
Edward Kotarski

Reputation: 744

This is now four years old, but I managed to get a typed version working without overloads:

There are a couple of nasty things needed:

There are two reasons we need lists of numbers:

  • Given a particular index we need to be able to retrieve the previous index
  • We need to be able to convert the stringy tuple indices to numeric indices
type SNumbers = [
   "0",  "1",  "2",  "3",  "4",  "5",  "6",  "7",  "8",  "9",  "10", "11", "12", "13", "14", "15",
   "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31",
   "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47",
   "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63"];

type Numbers = [
   0,  1,  2,  3,  4,  5,  6,  7,  8,  9,  10, 11, 12, 13, 14, 15,
   16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
   32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
   48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63];

// Util to prepend a value to a Tuple from: https://stackoverflow.com/a/54607819/5308589
type PrependTuple<A, T extends Array<any>> =
  (((a: A, ...b: T) => void) extends (...a: infer I) => void ? I : [])

// Get the previous number (for indexing)    (2=>1, 1=>0, 0=>never)
type PrevN<T extends number> = PrependTuple<never, Numbers>[T];

// Convert a string index to a number
type S_N<S extends SNumbers[number]> = {
   [K in SNumbers[number]]: Numbers[K]
}[S]

A couple of helpers:

Pipe/Compose act on unary functions

// Only unary functions wanted 
type Unary = (i: any) => any;

// Get the (single) argument of a given unary function
type ParameterUnary<F extends Unary> = Parameters<F>["0"]

// ReturnType is a builtin

The main types:

UnariesToPiped/UnariesToComposed take a tuple of unary functions and tries to map it to a tuple containing the correct function types

Then Pipe/Compose simply take the mapped tuple as an argument and pull the first parameter type and last return type.

type UnariesToPiped<F extends Unary[]> = {
   [K in keyof F]:
   K extends SNumbers[number] 
      ? K extends "0"
         ? F[K]
         : (i: ReturnType<F[PrevN<S_N<K>>]>) => ReturnType<F[S_N<K>]>
      : F[K]
}

type Pipe = <F extends Unary[]>(...funcs: UnariesToPiped<F>) => (i: ParameterUnary<F[0]>) => ReturnType<F[PrevN<F["length"]>]>

type UnariesToComposed<F extends Unary[]> = {
   [K in keyof F]:
   K extends SNumbers[number] 
      ? K extends "0"
         ? F[K]
         : (i: ParameterUnary<F[S_N<K>]>) => ParameterUnary<F[PrevN<S_N<K>>]>
      : F[K]
}

type Compose = <F extends Unary[]>(...funcs: UnariesToComposed<F>) => (i: ParameterUnary<F[PrevN<F["length"]>]>) => ReturnType<F[0]>

For examples of usage I published this to Github and NPM

Upvotes: 0

Jaroslav Šmol&#237;k
Jaroslav Šmol&#237;k

Reputation: 337

It might not be completely obvious why are the overloads still required. I played with various implementations and got to the core of the problem I belive, at least given one approach, which resolved around enumerate all chains bottom-up (which is slightly better than overloads, because you could self-refernce lower levels) and you should still get the type inference.

const a: [(_: string) => number, (_: number) => boolean] | [(_: string) => boolean] = [x => x.length, y => true]

The overloads are needed just to detect the tuple by its length. TS can manage it with overloads (select signature based on number of arguments), but fails to do that with plain tuples without function signatures. That is why y is not inferred in the snippet and why effort to make the solution more compact cannot sucees with the current state without overloads.

So approved answer seems to be the best soltion at the moment!

Upvotes: 0

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249676

There isn't a way to remove all the overloads. The way the type R* parameters depend on one another is not expressible in the type system currently.

One improvement we can make is to remove the need for overloads adding extra parameters on the first function (the ones that add the A* type parameters). This can be done in 3.0 using tuples in rest parameters

interface LoDashStatic {

    flow<A extends any[], R1, R2>(f1: (...a: A) => R1, f2: (a: R1) => R2): (...a: A) => R2;

    flow<A extends any[], R1, R2, R3>(f1: (...a: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3): (...a: A) => R3;

    flow<A extends any[], R1, R2, R3, R4>(f1: (...a: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4): (...a: A) => R4;

    flow<A extends any[], R1, R2, R3, R4, R5>(f1: (...a: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5): (...a: A) => R5;

    flow<A extends any[], R1, R2, R3, R4, R5, R6>(f1: (...a: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5, f6: (a: R5) => R6): (...a: A) => R6;

    flow<A extends any[], R1, R2, R3, R4, R5, R6, R7>(f1: (...a: A) => R1, f2: (a: R1) => R2, f3: (a: R2) => R3, f4: (a: R3) => R4, f5: (a: R4) => R5, f6: (a: R5) => R6, f7: (a: R6) => R7): (...a: A) => R7;

}

declare const _: LoDashStatic;

let f = _.flow((n: number, s: string) => n + s, o => o.toUpperCase()); // f: (n: number, s: string) => string

Upvotes: 3

Related Questions