typescripttypescript-genericsclass-hierarchy

Parallel type hierarchies in TypeScript


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:

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.

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?


UPDATE

To explain why I have such unusual set of types — I am trying to implement Model-View-ViewModel pattern in an app written in TypeScript.

Here's an example:

Base view model and the ProfileViewModel classes:

abstract class ViewModel
{
    protected notifyPropertyChanged(propertyName: string)
    {
        // some implementation here
    }
}

class ProfileViewModel extends ViewModel
{
    #firstName: string;
    #lastName: string;
    constructor(firstName: string, lastName: string)
    {
        super();
        this.#firstName = firstName;
        this.#lastName = lastName;
    }

    get firstName(): string
    {
        return this.#firstName;
    }

    set firstName(value: string)
    {
        this.#firstName = value;
        this.notifyPropertyChanged("firstName");
    }

    get lastName(): string
    {
        return this.#lastName;
    }

    set lastName(value: string)
    {
        this.#lastName = value;
        this.notifyPropertyChanged("lastName");
    }
}

Base view and the ProfileView classes:

abstract class View<T extends ViewModel>
{
    #viewModel: T;
    constructor(viewModel: T)
    {
        this.#viewModel = viewModel;
    }

    get viewModel(): T
    {
        return this.#viewModel;
    }

    abstract get content(): string;
}

class ProfileView extends View<ProfileViewModel>
{
    get content(): string
    {
        return "<StackPanel>" +
                   "<TextBlock text='{firstName}' />" +
                   "<TextBlock text='{lastName}' />" +
               "</StackPanel>";
    }
}

There is a separate renderer code that takes the string returned by the content property and constructs real UI elements. <StackPanel> and <TextBlock> describe the UI elements while {firstName} and {lastName} instruct the renderer to replace the these placeholders with the actual values of viewModel.firstName and viewModel.lastName properties respectively.

And here's the implementation that ties views and view models together:

class App
{
    #createView<T extends ViewModel>(viewModel: T): View<T>
    {
        // some implementation here
    }

    #render(content: string, viewModel: ViewModel): void
    {
        // some implementation here
    }

    navigateTo(viewModel: ViewModel): void
    {
        const view = this.#createView(viewModel);
        this.#render(view.content, view.viewModel);
    }
}

The question was about the implementation of the #createView method.


Solution

  • 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 :)