Reputation: 9487
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.
Upvotes: 2
Views: 56
Reputation: 327624
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!
Upvotes: 2