Home > database >  Parallel type hierarchies in TypeScript
Parallel type hierarchies in TypeScript

Time:10-19

I have two parallel type hierarchies:

class A {}
class A1 extends A {}
class A2 extends A {}

class B<TypeA extends A>
{
    a: TypeA;
    constructor(a: TypeA)
    {
        this.a = a;
    }
}
class B1 extends B<A1> {}
class B2 extends B<A2> {}

Each hierarchy has approximately hundred types, i.e. there are classes:

  • A1A100 derived from A, and
  • B1B100 derived from B.

I would like to implement a generic solution which creates a new instance of a subtype of B based on the type of an existing instance of a subtype of A. In other words:

const b1 = create(new A1()); // returns an instance of B1
const b2 = create(new A2()); // returns an instance of B2

I know that all generic type variables declared in TypeScript get removed in the resulting JavaScript code, so I am OK to supply the explicit mapping between the types somewhere in the code:

Type A Corresponds to type B
A1 B1
A2 B2
... ...

I'm just not sure how to store such mapping.

If necessary, I'm also OK with adding separate type IDs to use as a key for the mapping, i.e.:

enum TypeId
{
    TYPE_1,
    TYPE_2
}

the mapping:

Type ID Type A Corresponds to type B
TypeId.TYPE_1 A1 B1
TypeId.TYPE_2 A2 B2
... ... ...

and the instantiation:

const b1 = create(TypeId.TYPE_1, new A1()); // returns an instance of B1
const b2 = create(TypeId.TYPE_2, new A2()); // returns an instance of B2

I am not able to add the code to instantiate subtypes of B into the implementation of the subtypes of A. In other words, I cannot add new B1(this) into the implementation of A1 because the requirements prohibit A to depend on B.

If possible, I would prefer the solution to be generic rather than writing

if (a instanceof A1) return new B1(a);
else if (a instanceof A2) return new B2(a);
else ...

Is something like this possible in TypeScript / JavaScript?

CodePudding user response:

Silly me, we can use Extract to implement FindB like this:

type FindB<E extends readonly (readonly [typeof A, typeof B<A>])[], T extends A> = Extract<E[number], readonly [{ new (...args: any[]): T }, any]>[1];

Let us first define a map of A's to B's:

const entries = [
    [A1, B1],
    [A2, B2],
] as const;

const AtoB = new Map<typeof A, typeof B<A>>(entries);

Notice that I have made the map entries into its own variable with as const. This is important because we need to get the "exact type" of the entries for use later.

Then we create a type to find the right B type for a given A type (at the type-level):

type FindB<E extends readonly (readonly [typeof A, typeof B<A>])[], T extends A, R = {
    [K in keyof E]: InstanceType<E[K][0]> extends T ? E[K][1] : never;
}[keyof E]> = Extract<R, E[number][1]>;

This looks quite complicated but it's really just finding the A inside the entries, then giving the corresponding B. However, for some reason this gives us useless junk along with the right B, so there is Extract to filter that out.

Here's how create could be implemented:

function create<T extends InstanceType<typeof entries[number][0]>>(a: T): InstanceType<FindB<typeof entries, T>>;
function create(a: A) {
    return new (AtoB.get(a.constructor as typeof A)!)(a);
}

Quite simple now isn't it? We just have an extra overload (external signature) so TypeScript won't complain about the implementation returning the wrong type (even though it does at runtime).

Usage:

const b1 = create(new A1()); // returns an instance of B1
const b2 = create(new A2()); // returns an instance of B2

console.log(b1 instanceof B1); // true
console.log(b2 instanceof B2); // true

Playground

Not the prettiest solution yet, but I'll try to clean it up later :)

  • Related