2

Originally the question was two questions "clubbed" together, but after a discussion in the comments and some much needed aid from @jcalz, we've managed to brig it closer to what it looks like now.

You can find the full code examples at the end for easier copy-paste

The Problem:

You can find the type definitions and code examples below

I am trying to figure out a way on how to "compose" (as in function composition) multiple functions that are suppose to modify a single object by "extending" it with additional properties, into a single function that does all the extensions and is properly typed.

The functions in question are StoreEnhancers<Ext> (where Ext represents a plain object that the resulting object is extended with) and the result of their composition should also be a StoreEnhancer<ExtFinal> where ExtFinal should be a union of all the Ext of every enhancer that was passed into the composition.

No matter what I try, passing an array, using a spread operator (...) I cannot seem to be able to write a compose function that is capable extending an object with multiple StoreEnhancers and allowing typescript to infer the final Ext so that I can access all the properties these enhancers add.

Here are some definitions for more context:

Firstly, we can define a StoreEnhancer as a function that takes either a StoreCreator or an EnahncedStoreCreator and returns an EnhancedStoreCreator. Or in a more human readable terms, its a function that will take, as it's argument another function, used to create what we will call a "store object". A store enhancer will then "enhance" this store object, by adding more properties to it, and will return an "enhanced" version of a store object.

So let's define the types (very barebones, for simplicity sake)

type Store = {
   tag: 'basestore' // used just to make Store distinct from a base {}
}
type StoreCreator = () => Store
type EnhancedStoreCreator<Ext> = () => Store & Ext

// essentially one could say that:
// StoreCreator === EnhancedStoreCreator<{}>
// which allows us to define a StoreEnhancer as such:

type StoreEnhancer<Ext> = <Prev>(createStore: EnhancedStoreCreator<Prev>) => EnhancedStoreCreator<Ext & Prev>

And an implementation might look something like this:

const createStore: StoreCreator = () => ({ tag: 'basestore' })

const enhanceWithFeatureA: StoreEnhancer<{ featureA: string }> = createStore => () => {
  const prevStore = createStore()
  return { ...prevStore, featureA: 'some string' }
}

const enhanceWithFeatureB: StoreEnhancer<{ featureB: number }> = createStore => () => {
  const prevStore = createStore()
  return { ...prevStore, featureB: 123 }
}

const createStoreWithA = enhanceWithFeatureA(createStore)
const createStoreWithAandB = enhanceWithFeatureB(createStoreWithA)

const store = storeCreatorWithFeatureAandB()
console.log(store)
//  { 
//    tag: 'baseStore',
//    featureA: 'some string'
//    featureB: 123
//  }

Codesandbox link with the new (updated) code is here

Codesandbox link is with the original question's code is here

Dellirium
  • 1,362
  • 16
  • 30
  • I would love to drop code into my IDE and start working on it; unfortunately it seems to be interspersed with lots of text and has at least one typo in it, so my guess is that it never passed through an IDE to get here in the first place. Could you provide an easy-to-consume [mre] so I can get to the starting line without too much extra work? – jcalz Feb 02 '23 at 18:55
  • Sure, would you want to use an external, like a codesandbox? @jcalz – Dellirium Feb 02 '23 at 18:56
  • It should be plaintext in the question itself, although an external link is a nice supplement. Having text interspersed is okay, as long as there aren't typos or unrelated errors in it. – jcalz Feb 02 '23 at 18:57
  • 1
    By the way, I think [this approach](https://tsplay.dev/wXzP8m) might meet your needs, but I had to smack a few typos down to get there. If it works for you I could write up an answer, but I'd appreciate it if you could [edit] the question code to fix those same issues. – jcalz Feb 02 '23 at 19:00
  • I've added both links to the post, I've also changed some code not to have typeos, but I might not have managed to hunt all of them down, when I was writting the question I wrote it in my editor, but I wrote it "quick and dirty" and when pasting here I;ve changed the names to be more understandable, so some of them might still bleed through, either way, on codesandbox you can find a working example, one click away – Dellirium Feb 02 '23 at 19:19
  • 1
    Wait are you telling me that the scope of generic is ALL it was? I am flbergasted.... But it still leaves the question, how would I now use an array of these a as a composition? Would you be able to help with that? – Dellirium Feb 02 '23 at 19:27
  • Any luck managing to get a composition to work? @jcalz With your suggested scoping of a generic (btw kudos for that, I wasn't even aware that could ever make a difference, I've read the official TS docs(albeit a while ago) but never found any mention of scoping of generics and how they'd impact code.) Either way, with the scoping I can get 2 enhancers, 3 enhancers, x enhancers to work..... so long as I write them manually... Any attempt to use an array to iterate over has failed. I cannot seem to get TS to actually take an X amount of enhancers and get the resulting type. – Dellirium Feb 02 '23 at 20:13
  • Ah, right, the question is phrased as being *primarily* about composition of these things even though you didn't have them really working right separately. What do you want to do, [edit] the question so that the individual things are fine and then just focus on composition? Or split into two questions so that this one is about getting individual "enhancers" to work generically and then another one is about composition? The composition solution will probably look like [this](https://tsplay.dev/w1AlOw). – jcalz Feb 02 '23 at 20:42
  • I think it would make more sense to edit the question to have proper typings as per your suggestion, then make it more readily apparent that it is about composition, at which point I'd like you to post an answer. Does that seem ok? Also where in the world did you learn TS like this, this TupleToIntersection thing, what kind of sorcery is this, also, defining "...args" as an object whos keys are keys of an array, o.0, why does that even work, or worse how does that even manage to properly extract T. If you have the patience, when writting your answer, I'd like you to explain it. – Dellirium Feb 02 '23 at 21:00
  • I can explain a certain amount but mostly I'll be providing links to documentation when it comes to things like [mapped array/tuple types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-1.html#mapped-types-on-tuples-and-arrays) or [inference from mapped types](https://www.typescriptlang.org/docs/handbook/advanced-types.html#inference-from-mapped-types). – jcalz Feb 02 '23 at 21:07
  • Editing the question and looking forward to any insight you can provide, I've been bashing my head with this for the past 3 weeks, 8 hour work days... I understand I am not good at TS, but this is some next level hacking, editing now, will be up soon – Dellirium Feb 02 '23 at 21:08
  • Okay I'll do it when I get a chance; it might not be for a number of hours. – jcalz Feb 02 '23 at 21:55
  • Moving over the generic as suggested by @jcalz should solve these issues. What's the actual use-case though? And what doesn't work about the built-in [redux `StoreEnhancer` type](https://github.com/reduxjs/redux/blob/f8f62e8fdd6cd858416e6d5d5a26e469bf90665e/src/types/store.ts#L262)? Admittedly, I never write enhancers so I don't have experience using that type. It does confuse me that both the `next` and the return type are the enhanced version but I haven't fully wrapped my head around it. – Linda Paiste Feb 02 '23 at 22:46
  • I actually got a bunch of errors when I copy and pasted the [redux enhancer test code](https://github.com/reduxjs/redux/blob/f8f62e8fdd6cd858416e6d5d5a26e469bf90665e/test/typescript/enhancers.ts) into the TS Playground, so you might be on to something here. – Linda Paiste Feb 02 '23 at 22:47
  • Seems like there is [an open PR](https://github.com/reduxjs/redux/pull/3776#pullrequestreview-953557722) to fix the types in Redux and two [related](https://github.com/reduxjs/redux/issues/4283) [issues](https://github.com/reduxjs/redux/issues/3768) – Linda Paiste Feb 02 '23 at 22:59
  • There is loads of issues with the Redux's original version as it is, its horribly hard to work with, it lies about what it actually does, it doesnt truly describe what the js code actually does in some cases. And that is just if you want to use it, as is. I want to expand upon it, which makes the whole thing my worst nightmare... But I need it in order to make a typesafe environment for a completly decoupled modular framework(ish) sort of... – Dellirium Feb 03 '23 at 00:18
  • @jcalz I've updated the question and am looking forward to your answer, with as much explanation / detail as you can. The code you've given me works, but to my eyes it looks like an extremely hacky way of passing information to the TS compiler, information that it should be able to get other ways, but apparently, this works so I dont even anymore. – Dellirium Feb 03 '23 at 00:20
  • There you go. Some of this is somewhat advanced TS type juggling but I'd say "hackiness" is in the eye of the beholder... everything here is using supported features, although `TupleToIntersection` is a little closer to the edge than the rest of it. – jcalz Feb 03 '23 at 03:50
  • Hiya, folks. Out of curiosity, what's the _actual_ use case that sparked this question in the first place? Why are you wanting to write your own `composeEnhancers` method? What problem are you trying to solve? @dellirium can you clarify what you mean by "Redux's original `compose` lies and is hard to work with"? I haven't heard anyone say anything like that before. – markerikson Feb 04 '23 at 01:31
  • The whole thing is just a part of the "enhancements" i'm making in repackaging redux into a more "segregated" bundled version. The idea/point is to allow multiple smaller "bundles" to be written separately and to have them "precomipled" (for the lack of a better word) into a single store that you can use inside the app. I've already done this in plain JavaScript, but obviously with TS there is an added benefit of also being able to IntelliSense the structure and thus it makes the DevExp better. Continuing in the next comment: – Dellirium Feb 08 '23 at 14:31
  • The problem with using a normal compose is, it simply is not able to relay the type-information. The problem with all the other Redux parts is, they are not correct, or complete. Store enhancers problem with the PR Linda mentioned above is just the tip of the iceberg, createStore implementation is not aligned with types, its just a massive headache. Don't even get me started on middleware... Middleware is suppose to be able to intercept actions, yet you cannot make your actions anything that does not extend the base Action. – Dellirium Feb 08 '23 at 14:41

1 Answers1

2

The goal is to write a composeEnhancers() function which takes a variadic number of arguments, each of which is a StoreEnhancer<TI> value for some TI; that is, the arguments would be of a tuple type [StoreEnhancer<T0>, StoreEnhancer<T1>, StoreEnhancer<T2>, ... , StoreEnhancer<TN>]) for some types T0 through TN. And it should return a value of type StoreEnhancer<R> where R is the intersection of all the TI types; that is, StoreEnhancer<T0 & T1 & T2 & ... & TN>.


Before we implement the function, let's design its typings by writing out its call signature. From the above description, it seems that we are dealing with an underlying tuple type [T0, T1, T2, ... , TN] that gets mapped to become the input type. Let's call the tuple type T, and say that at every numeric-like index I, the element T[I] gets mapped to StoreEnhancer<T[I]>. Luckily, this operation is very straightforward to represent with a mapped type, because mapped types that operate on arrays/tuples also produce arrays/tuples.

So for now we have

declare function composeEnhancers<T extends any[]>(
    ...enhancers: { [I in keyof T]: StoreEnhancer<T[I]> }
): StoreEnhancer<???>;

where the rest parameter is of the relevant mapped tuple type. Note that this mapped type is homomorphic (see What does "homomorphic mapped type" mean? ) and thus the compiler can fairly easily infer T from a value of the mapped type (this behavior is called "inference from mapped types" and it used to be documented here but the new version of the handbook doesn't seem to mention it). So if you call composeEnhancers(x, y, z) where x is of type StoreEnhancer<X>, y is of type StoreEnhancer<Y>, and z is of type StoreEnhancer<Z>, then the compiler will readily infer that T is [X, Y, Z].


Okay, so what about that return type? We need to replace ??? with a type that represents the intersection of all the elements of T. Let's just give that a name:

declare function composeEnhancers<T extends any[]>(
    ...enhancers: { [I in keyof T]: StoreEnhancer<T[I]> }
): StoreEnhancer<TupleToIntersection<T>>;

And now we need to define TupleToIntersection. Well, here's one possible implementation:

type TupleToIntersection<T extends any[]> =
    { [I in keyof T]: (x: T[I]) => void }[number] extends
    (x: infer U) => void ? U : never;

This is using the feature where type inference in conditional types produces an intersection of candidate types if the inference sites are in a contravariant position (see Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript ) such as a function parameter. So I mapped T to a version where each element is a function parameter, combined them into a single union of functions, and then infer the single function parameter type, which becomes an intersection. It's a similar technique to what's shown in Transform union type to intersection type .


Okay, now we have a call signature. Let's make sure caller's see the desired behavior:

const enhanceWithFeatureA: StoreEnhancer<{ featureA: string }> =
    cs => () => ({ ...cs(), featureA: 'some string' });

const enhanceWithFeatureB: StoreEnhancer<{ featureB: number }> =
    cs => () => ({ ...cs(), featureB: 123 });

const enhanceWithFeatureC: StoreEnhancer<{ featureC: boolean }> =
    cs => () => ({ ...cs(), featureC: false });

const enhanceWithABC = composeEnhancers(
    enhanceWithFeatureA, enhanceWithFeatureB, enhanceWithFeatureC
);
/* const enhanceWithABC: StoreEnhancer<{
    featureA: string;
} & {
    featureB: number;
} & {
    featureC: boolean;
}> */

Looks good; the enhanceWithABC value is a single StoreEnhancer whose type argument is the intersection of the type arguments of the input StoreEnhancers.


And we're essentially done. The function still needs to be implemented, and the implementation is straightforward enough, but unfortunately because the call signature is fairly complicated there's no hope that the compiler can verify that the implementation actually adheres to the call signature perfectly:

function composeEnhancers<T extends any[]>(
    ...enhancers: { [I in keyof T]: StoreEnhancer<T[I]> }
): StoreEnhancer<TupleToIntersection<T>> {
    return creator => enhancers.reduce((acc, e) => e(acc), creator); // error!
    // Type 'EnhancedStoreCreator<Prev>' is not assignable to type 
    // 'EnhancedStoreCreator<TupleToIntersection<T> & Prev>'.
}

That will work at runtime, but the compiler has no clue that the array reduce() method will output the a value of the right type. It knows that you'll get an EnhancedStoreCreator but not specifically one involving TupleToIntersection<T>. This is essentially a limitation of the TypeScript language; the typings for reduce() can't be made sufficiently generic to even express the sort of progressive change of type from the beginning to the end of the underlying loop; see Typing a reduce over a Typescript tuple .

So it's best not to try. We should aim to suppress the error and just be careful to convince ourselves that our implementation is written correctly (because the compiler can't do it for us).


One way to proceed is to drop the "turn off typechecking" any type in where the trouble spots are:

function composeEnhancers<T extends any[]>(
    ...enhancers: { [I in keyof T]: StoreEnhancer<T[I]> }
): StoreEnhancer<TupleToIntersection<T>> {
    return (creator: EnhancedStoreCreator<any>) =>
            // ------------------------> ^^^^^
        enhancers.reduce((acc, e) => e(acc), creator); // okay
}

Now there's no error, and that's close to the best we can do here. There are other approaches to suppressing the error, such as type assertions or single-call-signature overloads, but I won't digress by exploring those in detail here.


And now that we have typings and an implementation, let's just make sure that our enhanceWithABC() function works as expected:

const allThree = enhanceWithABC(createStore)();
/* const allThree: Store & {
    featureA: string;
} & {
    featureB: number;
} & {
    featureC: boolean;
} & {
    readonly tag: "basestore";
} */

console.log(allThree);
/* {
  "tag": "basestore",
  "featureA": "123",
  "featureB": 123,
  "featureC": false
} */

console.log(allThree.featureB.toFixed(2)) // "123.00"

Looks good!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • You literally are, a life savior. I am still so very confused by the whole contra-variant union hack thingy, but it works now, and I am having issues of a different manner which I have to deal with now. I can just hope you, or someone with as much knowledge as you will see my next few posts here. I've been bashing my head for 8+ hours daily for over three weeks on this one... I've talked to every dev I know, asked in various Discord groups, had a lengthy conversation with chatGPT, no avail. I would ask one more thing (but running out of space on this comment) – Dellirium Feb 03 '23 at 04:11
  • The "trick" used to map over a tuple is a bit "reverse" in my head, you said that the type T is the tuple of [X, Y, Z] which gets mapped over to the inputs, which is the most counter-intuitive way of looking at it from my angle, but I get how it works. Would it be possible to use the same trick to "extract" information about return types from an array of functions that return plain objects, and then of course mash those objects together... and as I am writing this, I realize, yes, it would... Man this is some next level $h!7. Thanks for explanation, much appreciated. – Dellirium Feb 03 '23 at 04:15
  • 1
    Probably, but without a [mre] I wouldn't want to say for sure. I'll need to sign off for the night now in any case. – jcalz Feb 03 '23 at 04:17
  • Is it possible to use the same trick to infer/extract multiple generics info from a tuple? Say that a `StoreEnhancer` has two type arguments, would we be able to extract a tuple of `[T, G]` tuple types ? I've tried my hand but am quite confused. – Dellirium Feb 22 '23 at 19:45