I\'m writing a Typescript declarations file for my Javascript package.
My library has a function that accepts an array of superclass elements and returns the superclass.
(Using TS3.1 in the following:)
This answer is so full of caveats that I feel weird even posting it. Plus I don't really think I understand your use case. But the type juggling involved in locating the most specific type in a list has piqued my curiosity. So here we are!
The following could only possibly work if the objects you pass to x
contain at least one element that is an instanceof
the constructor for every single other value passed. That means the class hierarchy has no forks in it (or at least none in the list of things you pass to x
) and that it is an actual class hierarchy using prototypical inheritance.
Here goes:
type NotExtendsAll<T, U> = U extends any ? [T] extends [U] ? never : unknown : never;
type AbsorbUnion<T> = [T] extends [infer U] ? U extends any ?
NotExtendsAll<U, T> extends never ? U : never : never : never;
type Absorb<T extends any[]> = AbsorbUnion<{ [K in keyof T]: [T[K]] }[number]>[0];
function x<T extends any[]>(...args: T): Absorb<T> extends never ? undefined : Absorb<T>;
function x(...args: any[]): any {
return args.find(a => (args.every(b => a instanceof b.constructor)));
}
The explanation is a bit involved as it uses a lot of conditional types, especially distributed ones which allows you to inspect union constituents. The effect is that Absorb<>
takes an array (or tuple) type and returns the element which is a subtype of all the other elements, if there is one... otherwise it becomes the bottom type never.
In the x
function I'm also using rest parameters instead of an array because it helps infer tuple types for the passed-in parameters.
Let's see if it works:
class A { a: string = "a" }
class B extends A { b: string = "b" }
class C extends B { c: string = "c" }
let a = new A();
let b = new B();
let c = new C();
const aaa = x(a, a, a); // type is A, returns a at runtime
const aba = x(a, b, a); // type is B, returns b at runtime
const abc = x(a, b, c); // type is C, returns c at runtime
Looks right, I think.
Now, this doesn't work:
const none = x(); // type is never, returns undefined at runtime
I know you wanted it to be A
, but you're not handing it any parameters. How can it return a value of type A
when it doesn't have one? Oh well, we'll assume there's a value named a
defined in an outer scope. You can modify the above to make a zero-parameter x()
work:
function otherX<T extends A[]>(...args: T): Absorb<T> extends never ? A : Absorb<T>;
function otherX(...args: A[]): A {
return args.find(z => (args.every(b => z instanceof b.constructor))) || a;
}
const none = otherX(); // type is A, returns a at runtime
const otherAba = otherX(a, b, a); // type is B, returns B at runtime
const otherAbc = otherX(a, b, c); // type is C, returns C at runtime
Here are some caveats... if you use hierarchies with forks:
class D extends A { d: string = "d" }
let d = new D();
const whoops = x(a, b, c, d); // type is undefined, returns undefined at runtime
const alsoWhoops = otherX(b, c, d); // type is A, returns a at runtime
If you use non-class instances:
const huh = x("a","b","c"); // type is supposedly string, returns undefined at runtime
And probably other craziness can happen also. But that's as close as I can get. Hope that helps you. Good luck!