Home > Software design >  Inferring type argument from a chain in a functions composition [compose]
Inferring type argument from a chain in a functions composition [compose]

Time:02-03

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

CodePudding user response:

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

  • Related