Home > Enterprise >  Typescript compose function with conditional return type
Typescript compose function with conditional return type

Time:12-29

I created a simple typescript compose function
One of the functions I'm composing has a conditional return type based on the input
When I'm adding this function to the compose function, the conditional return type stops working

My question is why, and if there is a way I can make it work?

Here is an example

type Fn = (arg: any) => any

export function compose<Input, A1>(
  fn1: (input: Input) => A1,
): (input: Input) => A1;

export function compose<Input, A1, A2>(
  fn1: (input: Input) => A1,
  fn2: (input: A1) => A2,
): (input: Input) => A2;

export function compose(...args: Fn[]) {
    return (data: any) => args.reduce((acc, elem) => elem(acc), data) as any
}

/////////////////////////

const a = <I, O>(fn: (args: I) => O) => (args: I & {a: number}): O & {a: number} => {
  return {...fn(args as I), a: 1}
}

const b = <I, O>(fn: (args: I) => O) => <B extends boolean>(args: I & {b: B})
: B extends true ? O & {b: number} : O => {
  if(!args.b) return fn(args) as any
  return {...fn(args), b: 1}
}

const fn = (arg: {x: number}) => arg

compose(b, a)(fn)({
    x:1,
    a:1,
    b:true 
  }).b // <- Property 'b' does not exist

CodePudding user response:

This is a known limitation in higher order generic function inference implemented in microsoft/TypeScript#30215, as described in microsoft/TypeScript#30727. Although that issue is marked as "needs investigation", the implementer of this functionality commented that it's not obvious how to improve the situation.

In order to save my sanity I'm going to rename your I and O type parameters to keep track of which function they came from:

const a = <Ia, Oa>(fn: (args: Ia) => Oa) => 
  (args: Ia & {a: number}): Oa & {a: number} => {
  return {...fn(args as Ia), a: 1}
}

const b = <Ib, Ob>(fn: (args: Ib) => Ob) => 
  <B extends boolean>(args: Ib & {b: B})
: B extends true ? Ob & {b: number} : Ob => {
  if(!args.b) return fn(args) as any
  return {...fn(args), b: 1}
}

When you call compose(b, a), the compiler needs to infer A1 as a unification of the <B extends boolean>(args: Ib & {b: B}) => B extends true ? Ob & {b: number} : Ob returned by b and the (fn: (args: Ia) => Oa) accepted by a. It has to instantiate Ia and Oa to do this, and here is where the problem happens.

There is currently no facility to promote the "inner" type parameter B to an outer scope; instead the compiler loses the generic and replaces B with its constraint, boolean. That means Ia becomes Ib & {b: boolean} and Oa becomes Ob | (Ob & {b: number}) (since it was a distributive conditional type).

Everything else follows from there. The returned value is generic in Ib and Ob, and you get a value of type <Ib, Ob>(input: (args: Ib) => Ob) => (args: Ib & { b: boolean } & { a: number }) => (Ob | (Ob & { b: number })) & { a: number }. Oops.


As far as I know there's no way to "make it work". TypeScript lacks true higher kinded types as requested in microsoft/TypeScript#1213. Without higher kinded types there's no general way to express what you're trying to do. The current higher order generic function support from microsoft/TypeScript#30215 is a "best-effort" heuristic that only works in particular circumstances. And unfortunately your example isn't one of those circumstances.

Playground link to code

  • Related