Search code examples
typescriptgenericsunion-types

Type of class-field based on generic


Imagine the following code (playground):

type AvailableTypes = {
    'array': Array<any>;
    'string': string;
    'object': object;
}

class Wrapper<T extends keyof AvailableTypes> {

    // Is either array, string or object
    private readonly type: T;

    // ERROR: Property 'value' has no initializer and is not definitely assigned in the constructor.
    private readonly value: AvailableTypes[T];

    constructor(type: T) {
        this.type = type;

        /**
         * ERROR:
         *  TS2322: Type 'never[]' is not assignable to type 'AvailableTypes[T]'.   
         *  Type 'never[]' is not assignable to type 'never'.
         */
        switch (type) {
            case 'array':
                this.value = [];
                break;
            case 'string':
                this.value = '';
                break;
            case 'object':
                this.value = {};
                break;
        }
    }
}

There are two major errors:

TS2322: Type 'never[]' is not assignable to type 'AvailableTypes[T]'.
Type 'never[]' is not assignable to type 'never'

Even if AvailableTypes[T] always resolves to a one of the types declared in AvailableTypes, with T being the key of it.

... and

Property 'value' has no initializer and is not definitely assigned in the constructor.

Although type is mandatory and need to be either string, array or object.

What am I missing here?

Possible related SO Threads:

Update

(update to @jcalz answer) It should be possible to type-check the value based on the type property:

// In the Wrapper class, should work since value can only be an array if type is 'array':
public pushValue(val: unknown) {
    if (this.type === 'array') {
        this.value.push(val);
    }
}

Playground


Solution

  • The underlying issue is that generic type parameters are not narrowed via control flow analysis, which is a fairly longstanding open issue in TypeScript, see microsoft/TypeScript#24085 for more information.

    When you check type in a switch/case statement, the compiler can narrow the type of the type variable to the literal type "array", but it does not narrow the type parameter T to "array". And therefore it cannot verify that it's safe to assign an any[] value to the type AvailableTypes[T]. The compiler would have to perform some analysis it currently does not do, such as "okay, if type === "array", and we inferred T from the type of type, then inside this case block we can narrow T to "array", and therefore the type of this.value is AvailableTypes["array"], a.k.a., any[], and so it is safe to assign [] to it." But this doesn't happen.

    The same problem is causing the "value isn't definitely assigned" error. The compiler doesn't have the wherewithal to see that the switch/case exhausts all the possibilities for T since it doesn't do control flow analysis here.


    The easiest workaround here is to use type assertions to tell the compiler that you know what you're doing since it can't verify it.

    To handle the exhaustiveness issue, you can either make a default case that throws, as in:

    class Wrapper<T extends keyof AvailableTypes> {
    
        private readonly type: T;
        private readonly value: AvailableTypes[T];
        constructor(type: T) {
            this.type = type;
    
            switch (type) {
                case 'array':
                    this.value = [] as AvailableTypes[T]; // assert
                    break;
                case 'string':
                    this.value = '' as AvailableTypes[T]; // assert
                    break;
                case 'object':
                    this.value = {} as AvailableTypes[T]; // assert
                    break;
                default:
                    throw new Error("HOW DID THIS HAPPEN"); // exhaustive
            }
        }
    }
    

    or you can widen type from T to keyof AvailableTypes, which will let the compiler perform the control flow analysis necessary for it to understand that the cases are exhaustive:

    class Wrapper<T extends keyof AvailableTypes> {
        private readonly type: T;
        private readonly value: AvailableTypes[T];
        constructor(type: T) {
            this.type = type;
            const _type: keyof AvailableTypes = type; // widen to concrete type
            switch (_type) {
                case 'array':
                    this.value = [] as AvailableTypes[T]; // assert
                    break;
                case 'string':
                    this.value = '' as AvailableTypes[T]; // assert
                    break;
                case 'object':
                    this.value = {} as AvailableTypes[T]; // assert
                    break;
            }
        }
    }
    

    Another workaround (mentioned in a comment by person who implemented the soundness change) is to take advantage of the fact that if you have a value t of type T, and a key k of type K extends keyof T, then the type of t[k] will be seen by the compiler as T[K]. So if we can make a valid AvailableTypes, we can just index into it with type. Maybe like this:

    class Wrapper<T extends keyof AvailableTypes> {
        private readonly type: T
        private readonly value: AvailableTypes[T];
        constructor(type: T) {
            this.type = type;
            const initValues: AvailableTypes = {
                array: [],
                string: "",
                object: {}
            };
            this.value = initValues[type];
        }
    }
    

    This is by far a nicer way to go than type assertions and switch statements, and is fairly type safe to boot. So I'd go with this solution unless your use case prohibits it.


    Okay, hope one of those helps. Good luck!

    Playground link to code


    update: discriminated union instead of generic classes