ThomasReggi
ThomasReggi

Reputation: 59365

Chain functions and infer types from previous return

Is it possible to type the arguments of each one of these functions below, from the corresponding keys declared in the previous chain?

const helloWorld = example(
    ({}) => ({ greeting: 'Hello', name: 'Thomas' }),
    ({ greeting, name }) => ({ clean: `${greeting} ${name}` }),
    ({ clean }) => ({ cleanLength: clean.length }),
    ({ name }) => ({ nameLength: name.length }),
)

I have this CheckFuncs generic from here that checks if the types of from each function line up with each other.

type Tail<T extends readonly any[]> =
    ((...a: T) => void) extends ((h: any, ...r: infer R) => void) ? R : never;

type CheckFuncs<T extends readonly ((x: any) => any)[]> = { [K in keyof T]:
    K extends keyof Tail<T> ? (
        [T[K], Tail<T>[K]] extends [(x: infer A) => infer R, (x: infer S) => any] ? (
            [R] extends [S] ? T[K] : (x: A) => S
        ) : never
    ) : T[K]
}

function journey<T extends readonly ((x: any) => any)[]>(...t: CheckFuncs<T>) {

}

This is a little different from that.

Here we can assume a couple of things:

The ideal syntax:

const helloWorld = example(
    ({ greeting, name }) => ({ clean: `${greeting} ${name}` }),
    ({ clean }) => ({ cleanLength: clean.length }),
    ({ name }) => ({ nameLength: name.length }),
)

helloWorld({ greeting: 'Hello', name: 'Thomas' })

Is this possible?


Also interested in key'd version:

const helloWorld = mac({
    clean: ({ greeting, name }) => `${greeting} ${name}`,
    cleanLength: ({ clean }) => clean.length,
    nameLength: ({ name }) => name.length,
})

Here's an attempt but:

'a' is referenced directly or indirectly in its own type annotation.

const JourneyKeyed = <T extends JourneyKeyed.Objects<T>>(a: T) => {
    return a
}

const helloWorld = JourneyKeyed({
    greeting: ({ name }) => name === 'bob' ? 'Get out!' : 'Welcome',
    fullGreeting: ({ greeting, name }) => `${greeting} ${name}`,
})


namespace JourneyKeyed {
    type ThenArg<T> = T extends Promise<infer U> ? U : T
    type FirstArg<T extends any> =
        T extends [infer R, ...any[]] ? R :
        T extends [] ? undefined :
        T;
    type KeyedReturns<C, M extends Array<keyof C>> = {
        [K in M[number]]: C[K] extends ((...args: any[]) => any) ? FirstArg<ThenArg<ReturnType<C[K]>>> : never
    }
    type AllKeyedReturns<T> = KeyedReturns<typeof helloWorld, Array<keyof typeof helloWorld>>
    export type Objects<T extends object> = { [K in keyof T]: (a: AllKeyedReturns<T[K]>) => T[K] extends Func ? ReturnType<T[K]> : never }
}

Playground

Upvotes: 1

Views: 187

Answers (1)

jcalz
jcalz

Reputation: 328292

This is fairly ugly and I don't think I have the energy to explain it. It's messy enough that I'd strongly suggest abandoning anything that requires a "reduce" operation on tuple types in favor of a builder instead. I'll just show a sketch of what I'm doing first. Here's the code:

// extend as needed I guess
type LT = [never, 0, 0 | 1, 0 | 1 | 2, 0 | 1 | 2 | 3, 0 | 1 | 2 | 3 | 4,
  0 | 1 | 2 | 3 | 4 | 5, 0 | 1 | 2 | 3 | 4 | 5 | 6, 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
];
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type UI<U> = UnionToIntersection<U> extends infer O ? { [K in keyof O]: O[K] } : never;
type P0<T> = T extends (x: infer A) => any ? A : never;
type Ret<T> = T extends (x: any) => infer R ? R : never;
type Idx<T, K, D = never> = K extends keyof T ? T[K] : D;

type ScanFuncs<T extends readonly ((x: any) => any)[]> = {
  [K in keyof T]: (x: UI<ReturnType<Idx<T, Idx<LT, K>>> | P0<T[0]>>) => Ret<T[K]>
}

function coalesce<T extends readonly ((x: any) => any)[]>(
  ...args: T & ScanFuncs<T>
): (x: P0<T[0]>) => UI<ReturnType<T[number]> | P0<T[0]>>;
function coalesce(...args: readonly ((x: any) => any)[]) {
  return (x: any) => args.reduce((a, s) => Object.assign(a, s(a)), x)
}

Fundamentally you're checking each function in the tuple against all the preceding functions, so you need something like: for a given index key K from the tuple T, give me T[EverythingLessThan<K>] and manipulate it. There's no simple way to represent EverythingLessThan<K>, so I've made a tuple called LT with hardcoded values for K up to "8". It can be extended as needed, or replaced with some clever-yet-unsupported recursive type, as long as neither of them make it near production code for which I am responsible.

The ScanFuncs type alias converts a tuple T of one-arg function types into a compatible type, by comparing each function T[K] to another function whose return type is unchanged, but whose parameter type is the intersection of the first function's parameter type and all the prior functions return types'. I'm using UnionToIntersection in there, which might do bizarre things if your functions involve unions themselves. It could be guarded against, but with even more complexity, so I'm not bothering.

I implemented your example, which I called coalesce for want of a more inspired name, as a function which takes a tuple of one-arg callbacks of type T, checks it with ScanFuncs<T>, and returns a one-arg function whose parameter type is that of T[0] and whose return type is the intersection of T[0] and all the return types of T. Let's demonstrate it working:

const f = coalesce(
  ({ x, y }: { x: number, y: string }) => ({ z: y.length === x }),
  ({ z }: { z: boolean }) => ({ v: !z }),
  ({ y }: { y: string }) => ({ w: y.toUpperCase() })
)
const r = f({ x: 9, y: "newspaper" })
/* const r: {
    x: number;
    y: string;
    z: boolean;
    v: boolean;
    w: string;
} */
console.log(r);
// { x: 9, y: "newspaper", z: true, v: false, w: "NEWSPAPER" }

Looks good.

Do note that you pretty much have to annotate your callbacks like ({x, y}: {x: number, y: string}) => instead of ({x, y}) =>, since the latter will result in implicit any types. Any hopes you have of having the compiler infer the parameter types from the return values of prior arguments should be snuffed out due to a design limitation I've mentioned to you before.


Both this inference problem and the messiness of the tuple-reduce operation strongly hint to me that the idiomatic way to do this in TypeScript will be with a builder pattern instead. It could look something like this:

type CollapseIntersection<T> =
  Extract<T extends infer U ? { [K in keyof U]: U[K] } : never, T>

class Coalesce<I extends object, O extends object> {
  cb: (x: I) => (I & O)
  constructor(cb: (x: I) => O) {
    this.cb = x => Object.assign({}, x, cb(x));
  }
  build() { return this.cb as (x: I) => CollapseIntersection<I & O> }
  then<T>(cb: (x: I & O) => T) {
    return new Coalesce<I, O & T>(x => {
      const io = this.cb(x);
      return Object.assign(io, cb(io));
    });
  }
}

That might not be the best implementation, but you can see that the typings are considerably less crazy. The CollapseIntersection is really the only "weird" thing in there, and that's just to make ungainly types like {x: 1, y: 2} & {z: 3} & {w: 4} easier to work with as {x: 1, y: 2, z: 3, w: 4}.

The builder works by folding subsequent then() functions into its current callback, and keeping track only of the current output type and the overall input type.

You use it like this:

const f = new Coalesce(
  ({ x, y }: { x: number, y: string }) => ({ z: y.length === x })
).then(
  ({ z }) => ({ v: !z })
).then(
  ({ y }) => ({ w: y.toUpperCase() })
).build();

Note that the type inference works now, and you don't have to annotate z or y in the then() calls. You still have to annotate x and y in the initial new Coalesce() argument, but that makes sense since there's nowhere for the compiler to infer it from. And it behaves the same:

const r = f({ x: 9, y: "newspaper" })
/* const r: {
    x: number;
    y: string;
    z: boolean;
    v: boolean;
    w: string;
} */
console.log(r);
// { x: 9, y: "newspaper", z: true, v: false, w: "NEWSPAPER" }

Looks good!


Okay, hope that helps; good luck!

Link to code

Upvotes: 1

Related Questions