Typescript, turn Array of functions into merged type of all returned values

Viewed 170

So I have a an array of functions (or actually an object of functions but it doesn't matter) which returns a different objects such as this:

const arr = [
  () => ({ a: "a" }),
  () => ({ b: "b" })
]

and now I want to get a type that contains all the merged values such as:

{
  a: string;
  b: string;
}

If tried some reduce solutions but all I've gotten to is a type that looks like:

{ a: string } | { b: string }

which isn't what I'm looking for.

Any ideas?


Update 1

The array in the example is a simplification and the actual return values of the functions are unique and is therefore needed to be kept as is => I cannot use a generalized interface such as

interface ReturnValues {
  [key: string]: string;
}

Update 2

The problem is not of a JS kind but of TS and it's types. Ultimately I want to achieve this kind of functionality:

const result = arr.reduce((sum, fn) => Object.assign(sum, fn()), {})

and I want the type of result to be { a: string, b: string } so that I can call result.a and typescript will know that this is a string. If the result is { a: string } | { b: string }, calling result.a typescript says this is of the type any.

Also, for the ease of it, one can assume that there is no overlapping of the returning values of the functions.

4 Answers

you can use Array.reduce

const arr = [
  () => ({ a: "a" }),
  () => ({ b: "b" })
]

const obj = arr.reduce((acc, cur) => ({ ...acc, ...cur() }), {});

console.log(obj);

There are 3 primary methods of "mixing" javascript objects.

The process your looking to achieve is called a "mixin".

The older and more widely used method is to use whats called an extend function.

There are many ways to write an extend function, but they mostly look something like this:

const extend = (obj, mixin) => {
  Object.keys(mixin).forEach(key => obj[key] = mixin[key]);
  return obj;
};

here "obj" is your first object, and "mixin" is the object you want to mix into "obj", the function returns an object that is a mix of the two.

The concept here is quite simple. You loop over the keys of one object, and incrementally assign them to another, a little bit like copying a file on your hard drive.

There is a BIG DRAWBACK with this method though, and that is any properties on the destination object that have a matching name WILL get overwritten.

You can only mix two objects at a time, but you do get control over the loop at every step in case you need to do extra processing (See later on in my answer).

Newer browsers make it somewhat easier with the Object.Assign call:

Object.assign(obj1, mix1, mix2);

Here "obj1" is the final mixed object, and "mix1", "mix2" are your source objects, "obj1" will be a result of "mix1" & "mix2" being combined together.

The MDN article on "Object.Assign" can be found here: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign

Like the extend function above "Object Assign" WILL overwrite properties in the destination object, but it does have the advantage of doing many at a time. My example above only shows 2 "mix" objects, but you can in theory have as many as you like, and that comes in really useful when you have them all in array as you have.

In an array you can either map the objects into one function, and then use the spread operator available in newer browsers, or you can use for..in to loop over the collection.

If your using JQuery, you can use it's foreach method, and underscore.js has dozens of ways of looping.

Since your using TypeScript you can also combine a lot of this with typescripts looping operators too.

There is a 3rd way of merging objects, it's not widely used but it is gaining traction, and that's the "Flight-Mixin" approach that uses the Array prototype, it looks something like this:

const EnumerableFirstLast = (function () { // function based module pattern.
  const first = function () {
      return this[0];
    },
    last = function () {
      return this[this.length - 1];
    };
  return function () {      // function based Flight-Mixin mechanics ...
    this.first  = first;  // ... referring to ...
    this.last   = last;   // ... shared code.
  };
}());

EnumerableFirstLast.call(Array.prototype);

The idea here is that the two objects all ready have the functionality you require on them, so instead of "mixing" them, your just providing a single interface that delegates to them behind the scenes.

Beacuse your adding to the array prototype, you can now do things like the following:

const a = [1, 2, 3];
a.first(); // 1
a.last();  // 3

This might seem as if it's of no use, until you consider what you've in effect just done is added two new functions to a datatype you cannot normally control, this MIGHT if applied to your own objects allow you to add functions dynamically, that simply just grab the values you need to merge in a loop without too much trouble, it would however require a bit of extra planning which is why I'm adding this as more of an idea for further exploration rather than part of the solution.

This method is better suited for objects that are largely function based rather than data based as your objects seem to be.

Irrespective of which mixin method you use though, you will still need to iterate over your array collection with a loop, and you will still need to use spread to get all the keys and properties in one place.

If you consider something like

const myarr = [
  {name: "peter", surname: "shaw"},
  {name: "schagler", surname: "kahn"}
]

The way the spread operator works is to bust those array entries out into individual parts. So for example, IF we had the following function:

function showTwoNames(entryOne, entryTwo) {
  console.log(entryOne.name + " " + entryOne.surname);
  console.log(entryTwo.name + " " + entryTwo.surname);
}

You could call that function with the spread operator as follows:

showTwoNames(...myarr);

If your array had more than 2 entries in it, then the rest would be ignored in this case, the number of entries taken from the array is directly proportional to the number of arguments for the function.

You could if you wanted to do the following:

function showTwoNames(entryOne, entryTwo, ...theRest) {
  console.log(entryOne.name + " " + entryOne.surname);
  console.log(entryTwo.name + " " + entryTwo.surname);
  console.log("There are " + theRest.length + " extra entries in the array");
}

Please NOTE that I'm not checking for nulls and undefined or anything here, it should go without saying that you should ALWAYS error check function parameters especially in JavaScript/TypeScript code.

The spread operator can in it's own right be used to combine objects, it can be simpler to understand than other methods like "ObjectAssign" beacuse quite simply you use it as follows:

var destination = { ...source1, ...source2, ...source3); // for as many sources as needed.

Like the other methods this will overwrite properties with the same name.

If you need to preserve all properties, even identically named ones, then you have no choice but to use something like an extend function, but instead of just merging directly using a for-each as my first example shows, you'll need to examine the contents of "key" while also looking in the destination to see if "key" exists and renaming as required.

Update RE: the OP's updates

So being the curious kind I am, I just tried your updated notes on one of my Linux servers, Typescript version is 3.8.3, Node is 12.14.1 and it all seems to work just as you expect it to:

Server output from Ubuntu 16.04 with Node 12 and TS 3.8

I'm using all the latest versions, so it makes me wonder if your problem is maybe a bug in an old version of TS, or a feature that has only just been added in the newest build and is not present in the version your using.

Maybe try an update see what happens.

Since TypeScript doesn't have proper variadic type support yet (See this issue), the only real way to achieve what you're looking for is this:

const a = [{a:1},{b:2}] as const;
function merge<TA, TB>(a: TA, b: TB): TA & TB;
function merge<TA, TB, TC>(a: TA, b: TB, c: TC): TA & TB & TC;
function merge<TA, TB, TC, TD>(a: TA, b: TB, c: TC, d: TD): TA & TB & TC & TD;
function merge(...list: Array<any>): any {}
const b = merge(...a);

It seems that TypeScript doesn't have a native solution for this. But I found a workaround.

As mentioned in the question, using the reduce-method one gets a TS type of { a: string } | { b: string } (and to be clear, of course also a resulting object of { a: "a", b: "b" }.

However, to get from { a: string } | { b: string } to { a: string, b: string } I used the following snippet to merge the types:

type UnionToIntersection<U> = (U extends any
    ? (k: U) => void
    : never) extends (k: infer I) => void
      ? I
      : never;

So this would be my resulting code:

const arr = [
  () => ({ a: "a" }),
  () => ({ b: "b" })
]

const result = arr.reduce((sum, fn) => Object.assign(sum, fn()))
// Result is now { a: "a", b: "b" } 
// but the TS type is '() => ({ a: string } | { b: string })'

type ResultUnion = ReturnType<typeof result>
// ResultUnion = { a: string } | { b: string }

type ResultIntersection = UnionToIntersection<ResultUnion>
// This is where the magic happens
// ResultIntersection = { a: string } & { b: string}
// It's not _exactly_ what I wanted, but it does the trick.
// Done

Related