Objects composed of generic types in TypeScript (type recursion)

60 Views Asked by At

Given the following untyped TS:

const compose = (thunk: any): any => {
  const res = { ...thunk() };
  return { ...res, then: (f: any): any => compose(() => ({...res, ...f()})) };
};

We can use it to make composable objects:

const { foo, bar } = compose(() => ({foo: 1})).then(() => ({bar: 2}))
// foo: 1, bar: 2

But typing this in TS seems tricky, as the types are recursive.

The best I could come up with is:

type Compose<T> = (thunk: () => T) => T & { then: Compose<any> };

const compose2 = <T extends {}>(thunk: () => T): ReturnType<Compose<T>> => {
  const res = { ...thunk() };
  return { ...res, then: (f) => compose2(() => ({ ...res, ...f() })) };
};

This means that all the objects that fall out of compose2 are of type any. The end result I'd like to accomplish is something that's typed with all of the composed objects:

const combined = compose(() => ({foo: 1})).then(() => ({bar: 2}))
// type of combined: { foo: number } & { bar: number } & { then: … }

The way I see it we'd need some sort of Fix type that can tie the recursive knot, as the type for then in Compose needs to recurse.

Of course, there might be a way to do it if we inverted the signature of compose and somehow used CPS. I'm open for suggestions!

Note that a 2-ary compose, let's call it combine, is no issue:

const combine = <A extends {}, B extends {}>(a: () => A, b: () => B): A & B => ({...a(), ...b()});
const bar = combine(() => ({foo: 1}), () => combine(() => ({bar: 2}), () => ({baz: 3})) )

But it isn't particularly nice to write the statements, so I hoped to pass a closure from the resulting object so I wouldn't have to nest repeated function calls.

1

There are 1 best solutions below

2
On BEST ANSWER

I think you might be looking for this:

type Compose<O extends object = {}> =
    <T extends object>(thunk: () => T) => T & O & {
        then: Compose<T & O>
    }

const compose: Compose = (thunk) => {
    const res = { ...thunk() };
    return { ...res, then: f => compose(() => ({ ...res, ...f() })) };
};

The return type of then() carries some state information you need to represent in your compose type. If we think of O as "the current state object", then Compose<O> is a generic function which takes a thunk of type () => T for any other object type T, and returns a T & O & {then: Compose<T & O>}... that is, the new object is the intersection of T and O, and its then() method has T & O as the new state.

The fact that the implementation of compose() type checks is a good sign. Let's verify that the compiler understands how calls work:

const { foo, bar } = compose(() => ({ foo: 1 })).then(() => ({ bar: "hello" }));

console.log(foo.toFixed(2)) // 1.00
console.log(bar.toUpperCase()); // HELLO

Looks good!

Playground link to code