Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Restricting generic types to one of several classes in Typescript

In Typescript, how do you restrict a generic type to one of several classes at compile time? For example, how do you implement this pseudocode?

class VariablyTyped<T has one of types A or B or C> {

    method(hasOneType: T) {
        if T has type A:
            do something assuming type A
        if T has type B:
            do something assuming type B
        if T has type C:
            do something assuming type C
    }
}

Moreover, I'd like to be able to assign a property (or any variable) to the specific descendent type of one of the generic type options, rather than just to one of the given types. For example:

class VariablyTyped<T has one of types A or B or C> {

    descendentClassOfT: T

    method(hasOneType: T) {
        descendentClassOfT = hasOneType
    }
}

class D extends class C {
    methodUniqueToD() { }
}

const v = new VariablyTyped(new D())
v.descendentClassOfT.methodUniqueToD()

This answer apparently isn't obvious, as I spent several hours on it. It appeared to me that some form of this question had already been asked, but the solutions given don't even compile for me. It may be that the prior question was answered only in a very specific context, as the number of up-votes suggests that it's solving some people's problems.

I'm posting this new question to clearly state the general problem and follow up with a solution.

like image 794
Joe Lapp Avatar asked Jan 31 '18 10:01

Joe Lapp


1 Answers

I banged my head on this for a few hours, but the solution seems obvious in retrospect. First I present the solution, then I compare it to prior approaches. (Tested in Typescript 2.6.2.)

// WORKING SOLUTION: union of types with type checks

class MustBeThis {
    method1() { }
}

class OrThis {
    method2() { }
}

abstract class OrOfThisBaseType {
    method3a() { }
}

class ExtendsBaseType extends OrOfThisBaseType {
    method3b() { }
}

class GoodVariablyTyped<T extends MustBeThis | OrThis | OrOfThisBaseType> {
    extendsBaseType: T;

    constructor(hasOneType: T) {
        if (hasOneType instanceof MustBeThis) {
            hasOneType.method1();
        }
        else if (hasOneType instanceof OrThis) {
            hasOneType.method2();
        }
        // either type-check here (as implemented) or typecast (commented out)
        else if (hasOneType instanceof OrOfThisBaseType) {
            hasOneType.method3a();
            // (<OrOfThisBaseType>hasOneType).method3a();
            this.extendsBaseType = hasOneType;
        }
    }
}

The following checks of this solution compile just fine:

const g1 = new GoodVariablyTyped(new MustBeThis());
const g1t = new GoodVariablyTyped<MustBeThis>(new MustBeThis());
const g1e: MustBeThis = g1.extendsBaseType;
const g1te: MustBeThis = g1t.extendsBaseType;

const g2 = new GoodVariablyTyped(new OrThis());
const g2t = new GoodVariablyTyped<OrThis>(new OrThis());
const g2e: OrThis = g2.extendsBaseType;
const g2te: OrThis = g2t.extendsBaseType;

const g3 = new GoodVariablyTyped(new ExtendsBaseType());
const g3t = new GoodVariablyTyped<ExtendsBaseType>(new ExtendsBaseType());
const g3e: ExtendsBaseType = g3.extendsBaseType;
const g3te: ExtendsBaseType = g3t.extendsBaseType;

Compare the above approach with the previously accepted answer that declared the generic to be the intersection of the class options:

// NON-WORKING SOLUTION A: intersection of types

class BadVariablyTyped_A<T extends MustBeThis & OrThis & OrOfThisBaseType> {
    extendsBaseType: T;

    constructor(hasOneType: T) {
        if (hasOneType instanceof MustBeThis) {
            (<MustBeThis>hasOneType).method1();
        }
        // ERROR: The left-hand side of an 'instanceof' expression must be of type
        // 'any', an object type or a type parameter. (parameter) hasOneType: never
        else if (hasOneType instanceof OrThis) {
            (<OrThis>hasOneType).method2();
        }
        else {
            (<OrOfThisBaseType>hasOneType).method3a();
            this.extendsBaseType = hasOneType;
        }
    }
}

// ERROR: Property 'method2' is missing in type 'MustBeThis'.
const b1_A = new BadVariablyTyped_A(new MustBeThis());
// ERROR: Property 'method2' is missing in type 'MustBeThis'.
const b1t_A = new BadVariablyTyped_A<MustBeThis>(new MustBeThis());

// ERROR: Property 'method1' is missing in type 'OrThis'.
const b2_A = new BadVariablyTyped_A(new OrThis());
// ERROR: Property 'method1' is missing in type 'OrThis'.
const b2t_A = new BadVariablyTyped_A<OrThis>(new OrThis());

// ERROR: Property 'method1' is missing in type 'ExtendsBaseType'.
const b3_A = new BadVariablyTyped_A(new ExtendsBaseType());
// ERROR: Property 'method1' is missing in type 'ExtendsBaseType'.
const b3t_A = new BadVariablyTyped_A<ExtendsBaseType>(new ExtendsBaseType());

Also compare the above working approach with another suggested solution in which the generic type is constrained to extend an interface that implements all of the class interface options. The errors occurring here suggest that it is logically identical to the prior non-working solution.

// NON-WORKING SOLUTION B: multiply-extended interface

interface VariableType extends MustBeThis, OrThis, OrOfThisBaseType { }

class BadVariablyTyped_B<T extends VariableType> {
    extendsBaseType: T;

    constructor(hasOneType: T) {
        if (hasOneType instanceof MustBeThis) {
            (<MustBeThis>hasOneType).method1();
        }
        // ERROR: The left-hand side of an 'instanceof' expression must be of type
        // 'any', an object type or a type parameter. (parameter) hasOneType: never
        else if (hasOneType instanceof OrThis) {
            (<OrThis>hasOneType).method2();
        }
        else {
            (<OrOfThisBaseType>hasOneType).method3a();
            this.extendsBaseType = hasOneType;
        }
    }
}

// ERROR: Property 'method2' is missing in type 'MustBeThis'.
const b1_B = new BadVariablyTyped_B(new MustBeThis());
// ERROR: Property 'method2' is missing in type 'MustBeThis'.
const b1t_B = new BadVariablyTyped_B<MustBeThis>(new MustBeThis());

// ERROR: Property 'method1' is missing in type 'OrThis'.
const b2_B = new BadVariablyTyped_B(new OrThis());
// ERROR: Property 'method1' is missing in type 'OrThis'.
const b2t_B = new BadVariablyTyped_B<OrThis>(new OrThis());

// ERROR: Property 'method1' is missing in type 'ExtendsBaseType'.
const b3_B = new BadVariablyTyped_B(new ExtendsBaseType());
// ERROR: Property 'method1' is missing in type 'ExtendsBaseType'.
const bt_B = new BadVariablyTyped_B<ExtendsBaseType>(new ExtendsBaseType());

Ironically, I later solved my app-specific problem without having to constrain the generic type. Perhaps others should learn from my lesson and first try to find another, better way to do the job.

like image 188
Joe Lapp Avatar answered Sep 18 '22 18:09

Joe Lapp