1

I have a function in javascript that takes an array of 0 or more objects of type {string => async fn*(...) => ...} (note the * - they are async generator functions).

The function returns a single object that is a union of the set of all keys in each input object, and the values are async functions (not generators) that match the APIs for each input object - the requirement here being that if two input objects share a key, they must also share the function prototype exactly.

For example:

function makeAggregateObject(objects) { ... }

const fish = {
    swim: async function*(distance) { return distance > 10; }, // (number) => boolean
    die: async function*() {} // () => void
};

const cat = {
    die: async function*() { this.lives -= 1; } // () => void
};

const human = {
    walk: async function*(steps, hop) { ... } // (number, boolean) => void
    swim: async function*(distance) { return false; } // (number) => boolean
};

const aggregate = makeAggregateObject([
    human, cat, fish
]);

console.log(aggregate);
/*
    {
        swim: async function(number) => boolean,
        walk: async function(number, boolean) => void,
        die: async function() => void
    }
*/

As I mentioned earlier, in the case I added e.g. die(number) => boolean to human above, it would be considered an error (of course in Javascript, there's no way to actually enforce this, but I would like to in Typescript) because the prototype (number) => boolean does not match the previously defined () => void prototypes before it.

Is this even possible in Typescript? How would I go about doing so?

Qix - MONICA WAS MISTREATED
  • 14,451
  • 16
  • 82
  • 145

1 Answers1

4

You can enforce this using generic types on makeAggregateObject. The issue of generators is not really a problem, this will enforce all common properties to have compatible types.

The way we will do it is to use a type parameter T to capture in a tuple type all the argument types. We then say that the parameter must be T (which is how we get the argument types into T) but it also must be a type where each element is an intersection of all the types in the tuple. Basically if typescript infers from the argument that T = [A, B], then the argument must also be Array<Partial<A & B>>. This will ensure that if a a property exists in both A and B, it must be an intersection of both property types. So if for example die is (n:number) => boolean in one object, but (s: string) => boolean in the other, you will get an error on both objects that the property is not compatible with ((n:number) => boolean) & ((s: string) => boolean).

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

function makeAggregateObject<T extends [any] | any[]>(objects: T & Partial<UnionToIntersection<T[number]>>[]) :UnionToIntersection<T[number]> { 
    return null!
}

const fish = {
    swim: async function*(distance: number) { return distance > 10; }, // (number) => boolean
    die: async function*() { } // () => void
};

const cat = {
    lives: 9,
    die: async function*() { /* this.lives -= 1; */ } // () => void
};

const human = {
    walk: async function*(steps: number, hop: boolean) { }, // (number, boolean) => void
    swim: async function*(distance: number) { return false; }, // (number) => boolean
    // die: (n : number) =>true // uncomment this and you get an error 
};

const aggregate = makeAggregateObject([
    human,
    cat,
    fish
]);


// const aggregate: {
//     swim: (distance: number) => AsyncGenerator<never, boolean, unknown>;
//     die: () => AsyncGenerator<never, void, unknown>;
// } & {
//     lives: number;
//     die: () => AsyncGenerator<never, void, unknown>;
// } & {
//     walk: (steps: number, hop: boolean) => AsyncGenerator<never, void, unknown>;
//     swim: (distance: number) => AsyncGenerator<never, boolean, unknown>;
// }

aggregate.swim(0) // ok
aggregate.die() // also ok 
console.log(aggregate);

Playground Link

Note: You can also express the signature of makeAggregateObject as:

function makeAggregateObject<T>(objects: T[] & Partial<UnionToIntersection<T>>[]) :UnionToIntersection<T> { 
    return null!
}

The signature above is functionally equivalent functionally, but produces differently worded error messages. See which one you think is more readable. Neither are great. We can think of a more complicated type that introduces additional information into the type, but that complicates the types and is a bit abusive to the type system. If you are interested let me know and I can provide such a version.

Plug: UnionFromIntersection is taken from here where you can also read the explanation of how it works make sure to upvote that great answer.

Edit

We can also extract the return type from the AsyncGenrator return type and use it to create a Promise type, as the new requirements of the question outline:


type MakeAggregateObject<T> = {
    [P in keyof T]: T[P] extends (...p: infer P) => AsyncGenerator<never, infer R, any> ? (...p: P)=> Promise<R> : never
}
function makeAggregateObject<T extends [any] | any[]>(objects: T & Partial<UnionToIntersection<T[number]>>[]) :MakeAggregateObject<UnionToIntersection<T[number]>> { 
    return null!
}

Playground Link

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • 1
    Nice addition of `cat.lives = 9` – Parzh from Ukraine Sep 04 '20 at 13:19
  • Hi there - this worked, but only almost! I totally forgot the return function types are async functions, not async generators. The input is an array of objects with async generator values but the return values are not generators. Hopefully that makes sense - I've updated the question to reflect this. In this case, how would the types look? – Qix - MONICA WAS MISTREATED Sep 05 '20 at 08:49
  • For context, [this library](https://github.com/qix-/scaly) is what I'm attempting to apply types to. There are very specific rules of the generator types/usage and the way `yield` is used, and I'm now realizing it's maybe not as straightforward as my original question leads on. Perhaps answering the edited question as-is is enough for now, though. Up to you :) – Qix - MONICA WAS MISTREATED Sep 05 '20 at 09:07
  • Pre-emptively awarding the bounty though, since the answer worked correctly for the original question asked. :) – Qix - MONICA WAS MISTREATED Sep 05 '20 at 09:08
  • @Qix-MONICAWASMISTREATED Soo.. the source objects contain async generator functions (ex `AsyncGenerator`), and the result object contains async function and the return of therse functions should be (`Promise`) ? What if the return types of the generators are different ? – Titian Cernicova-Dragomir Sep 06 '20 at 15:53
  • Return types should be the same between all overlapping methods (and error if not); the yield type is any. – Qix - MONICA WAS MISTREATED Sep 06 '20 at 17:35
  • @Qix-MONICAWASMISTREATED added a new version that is in line with the requirements I think. If this is still not exactly what you need feel free to reach out to me on mail or twitter, I'm sure we can get it to work – Titian Cernicova-Dragomir Sep 07 '20 at 06:15