1

Consider the following types:

type objectExtensionFunction<TBaseObject extends object, TExtendWith extends object> = (base: TBaseObject) => TExtendWith & TBaseObject;

declare function myEndpoint<TObjects extends objectExtensionFunction<any, any>>(objects: TObjects): any;

The above function accepts any array of objectExtensionFunction types. These are functions, that will receive a base object, and should add an additional property to that object, and create a new object type, based on the property added.

What I would want to achieve, is somehow tell myEndpoint function, that the minimal input array, should contain functions, that will at least add some specific properties to the first base object supplied, if all of the functions are called on a base object.

In other words:

/** 
 * how could I declare `myEndPoint` function, that it will only accept an array of  
 * `objectExtensionFunction` types, if the merging of all the function results of those 
 * `objectExtensionFunction` types extends my supplied base type
 */
declare function myEndPoint(): any;

A concrete example to illustrate the problem I am trying to solve:

type requiredEndType = {
   a: boolean,
   b: boolean
}

type objectExtensionFunction<TBaseObject extends object, TExtendWith extends object> = (base: TBaseObject) => TExtendWith & TBaseObject;

const addToBaseA: objectExtensionFunction<{}, {
   a: boolean
}> = (base) => {
    return {
        ...base,
        a: true
    };
}

const addToBaseB: objectExtensionFunction<{}, {
   b: boolean
}> = (base) => {
    return {
        ...base,
        b: true
    };
}

const addToBaseC: objectExtensionFunction<{}, {
   c: boolean
}> = (base) => {
    return {
        ...base,
        c: true
    };
}


/** I am looking to type this function, so that it will accept an array of `objectExtensionFunction` types
 *  that when called in succession, chained, providing an empty object as the first object parameter, will resolve 
 *  in an object, that satisfies the `requiredEndType` constraint
 */
declare function myEndpoint<TObjects extends objectExtensionFunction<any, any>[]>(objects: TObjects): void;

myEndpoint([addToBaseA, addToBaseB]); // this should be accepted
myEndpoint([addToBaseA, addToBaseB, addToBaseC]); // this also should be accepted
myEndpoint([addToBaseB, addToBaseA]); // this also should be accepted, the order does not matter
myEndpoint([addToBaseA, addToBaseC]); // this should raise an error, after running all the functions, the `b` property would be missing

Here's a typescript playground link of the above code.

Adam Baranyai
  • 3,635
  • 3
  • 29
  • 68
  • Your example seems to be pseudocode? There's no `define` keyword. Your functions are parenthesized so they are inaccessible to `myEndpoint`, and there are unrelated implicit `any` errors in there. Could you please check the example code in an IDE and clear up any errors unrelated to the question you're asking, and then [edit] this so that when others look at your code it is a [mre] they can immediately start working on? Thanks! – jcalz Feb 20 '23 at 15:02
  • @jcalz made the edits and added a playground link as well - as my original problem is a bit harder to explain, I've just wrote a quick example here, without an ide, and it seems that I know much fewer of typescript form my mind, than I tought:P – Adam Baranyai Feb 20 '23 at 15:20
  • • I still see `define` in the code above. Could you make sure all code in your question has at least briefly passed through a TypeScript IDE? • Does [this approach](https://tsplay.dev/w846Ew) meet your needs? If so I could write up an answer explaining; if not, what am I missing? – jcalz Feb 20 '23 at 15:24
  • @jcalz Yes, that is exactly what I was looking for - but a bit of explaining would also be needed, cause I would like to understand how is the magic happening:) – Adam Baranyai Feb 20 '23 at 15:35
  • I'd be happy to write up an answer with an explanation; could you please fix `define` and any other pseudocode? Maybe the title should say "accept" instead of "except" also? – jcalz Feb 20 '23 at 15:50

1 Answers1

1

Here's one approach:

declare function myEndPoint<T extends any[]>(
    objects: readonly [...{ [I in keyof T]: ObjectExtensionFunction<{},
        T[I] &
        Partial<RequiredEndType> &
        { [K in keyof RequiredEndType as Exclude<K, TupleToKeys<T>>]: RequiredEndType[K] }
    > }]): any;

type TupleToKeys<T extends readonly any[]> = { [I in keyof T]: keyof T[I] }[number];

The idea is that myEndPoint() is generic in T, the tuple type of second type arguments to ObjectExtensionFunction<{}, ?> for the corresponding elements of the objects parameter. So if you call myEndPoint([x, y, z]) where x is of type ObjectExtensionFunction<{}, X>, y is of type ObjectExtensionFunction<{}, Y>, and z is of type ObjectExtensionFunction<{}, Z>, then T should be the tuple type [X, Y, Z]. That much could be accomplished by

declare function myEndPoint<T extends any[]>(
    objects: readonly [...{ [I in keyof T]: ObjectExtensionFunction<{},
        T[I]> }]): any;

in which objects is a variadic tuple type (the [...+] part) to give the compiler a hint that it should infer a tuple type for T, and a mapped array/tuple type so that a T like [X, Y, Z] would be mapped to [ObjectExtensionFunction<{}, X>, ObjectExtensionFunction<{}, Y>, ObjectExtensionFunction<{}, Z>]. It is a homomorphic mapped type (as described in What does "homomorphic mapped type" mean? ) which is why the compiler could infer T from the mapped type

But that doesn't care what the elements of T are. We want to analyze T to make sure that: no element has properties incompatible with RequiredEndType; and each property of RequiredEndType is present in some element.

The part where no properties are incompatible is represented by intersecting T[I] with Partial<RequiredEndType>. If T[I] has any incompatible property, then that intersection will produce the impossible never type for that property, and you'll get an error.

The part where each property is present is represented by intersecting by { [K in keyof RequiredEndType as Exclude<K, TupleToKeys<T>>]: RequiredEndType[K] }. The construct {[K in keyof U as Exclude<K, P>]: U[K]} acts like Omit<U, P> using the Omit utility type. It uses key remapping to implement it, but it's the same operation; so what are we omitting? We are taking RequiredEndType, and omitting all the properties in TupleToKeys<T>. What's TupleToKeys<T>? That definition grabs the union of all the keys of all the elements of T. So that whole term is essentially an object containing properties of RequiredEndType which are not mentioned in any of the elements of T. If that is empty, everything is fine. Otherwise all the members of T will fail to type check because we are requiring properties not present in any member.


Okay, let's test it out:

declare const addToBaseA: ObjectExtensionFunction<{}, { a: boolean }>;
declare const addToBaseB: ObjectExtensionFunction<{}, { b: boolean }>;
declare const addToBaseC: ObjectExtensionFunction<{}, { c: boolean }>;   
 
myEndPoint([addToBaseA, addToBaseB]);
myEndPoint([addToBaseA, addToBaseB, addToBaseC]);
myEndPoint([addToBaseB, addToBaseA]);
myEndPoint([addToBaseA, addToBaseC]); // error!
// -------> ~~~~~~~~~~  ~~~~~~~~~~
// not assignable to type '{ a: boolean; } & Partial<RequiredEndType> & { b: boolean; }'
// not assignable to type '{ c: boolean; } & Partial<RequiredEndType> & { b: boolean; }'

declare const addToBaseBBad: ObjectExtensionFunction<{}, { b: number }>;
myEndPoint([addToBaseA, addToBaseBBad]); // error!
// -------------------> ~~~~~~~~~~~~~
//   Type '{ b: number; }' is not assignable to type 'never'.

Looks good. The first three calls succeed as desired. The fourth call fails because b was forgotten and neither of the elements' return types extends {b: boolean}. And the fifth call fails because addToBaseBBad because b was mistyped.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Hmm - I am trying to play around with this a bit, just to understand it a bit more, but for the life of me can't figure out, why does it suddenly stop working, if I change the declaration of the `ObjectExtensionFunction` to `type ObjectExtensionFunction = (base: T, next: X) => void;` (basically, removing the need of that function to return a value) - you are not checking the return type of the functions anywhere, so why does that break it? – Adam Baranyai Feb 20 '23 at 16:44
  • 1
    You've changed the `X` type parameter from a covariant to a contravariant position, so type relationships change in direction (e.g., if `X1 extends X2` then now `ObjectExtensionFunction<{}, X2> extends ObjectExtensionFunction<{}, X1>`), so the check doesn't do what it did originally. This seems like a followup question and out of scope for the question as asked, so I don't think I'm going to engage much here, if that's okay. – jcalz Feb 20 '23 at 16:48