Search code examples
javascripttypescriptobjectgenerics

Does Typescript allow Map to have different generics?


I'm new to Typescript and trying to learn how to create a class that has a property of type Map<string, Val<T>>, where type Val<T> = string | number | Array<T>. I'm running into trouble writing a constructor for it because Typescript wants me to declare what <T> is even if I'm picking a string or number. I also think I will run into trouble because the Map could contain more than one array with different T. It also fails when passing into a constructor but not a method.

My code looks like this

type Val<T> = string | number | Array<T>

This throws an error

export class MyClass<T> {
    model;

    constructor(model: [string, Val<T>][]) {
        this.model = new Map<string, Val<T>>(model);
    }
}

const c = new MyClass([
    ["key1", "val1"],
    ["key2", 99],
    ["key3", ["cat", "dog"]],
    ["key4", [1,2,3]],
]);
src/fakefuse.ts:38:15 - error TS2322: Type 'number' is not assignable to type 'string'.
38     ["key4", [1,2,3]],
src/fakefuse.ts:38:17 - error TS2322: Type 'number' is not assignable to type 'string'.
38     ["key4", [1,2,3]],
src/fakefuse.ts:38:19 - error TS2322: Type 'number' is not assignable to type 'string'.
38     ["key4", [1,2,3]],

Oddly enough, for some reason, using a method versus the constructor compiles without any error

class MyClass<T> {
    model;

    constructor() {
        this.model = new Map<string, Val<T>>();
    }

    init(model: [string, Val<T>][]) {
        this.model = new Map<string, Val<T>>(model);
    }
}

const c = new MyClass();
c.init([
    ["key1", "val1"],
    ["key2", 99],
    ["key3", ["cat", "dog"]],
    ["key4", [1,2,3]],
]);

This seems to only work when you pass in the value directly to c.init! If you tried a variable, you'd get this.

const c = new MyClass();
const aVar = [
    ["key1", "val1"],
    ["key2", 99],
    ["key3", ["cat", "dog"]],
    ["key4", [1,2,3]],
];
c.init(aVar);
src/fakefuse.ts:45:8 - error TS2345: Argument of type '((string | number)[] | (string | string[])[] | (string | number[])[])[]' is not assignable to parameter of type '[string, Val<unknown>][]'.
  Type '(string | number)[] | (string | string[])[] | (string | number[])[]' is not assignable to type '[string, Val<unknown>]'.
    Type '(string | number)[]' is not assignable to type '[string, Val<unknown>]'.
      Target requires 2 element(s) but source may have fewer.

45 c.init(aVar);

Can someone help me understand how I should be doing this? I feel like maybe this is what extends does but I don't really know how that works?


Solution

  • Oddly enough, for some reason, using a method versus the constructor compiles without any error

    That's because when you do

    const c = new MyClass();
    

    you don't pass any class to the type argument, making it of type MyClass<unknown>.

    Therefore, when you do after

    c.init(...);
    

    It'll accept anything, because in theory anything is assignable to unknown.


    Why it doesn't work when you pass it directly to the constructor? Because when creating the object, if you don't pass any type argument, Typescript will try to infer from the constructor argument:

    const c = new MyClass([
        ["key1", "val1"],
        ["key2", 99],
        ["key3", ["cat", "dog"]],
        ["key4", [1,2,3]],
    ]);
    

    For each item of the list, Typescript will try to infer their types: the first element is a string, the second element is a number and the third element is a Array<string>. It pauses there and infers T as string, making c a MyClass<string>.

    It now looks to the fourth element, a Array<number>, and correctly complains, because if T is inferred as string, then it must only contain either strings, numbers or Array<string>s.


    To make it not complain, you can explicitly declare the type argument of the class as a union between string and number:

    type Val<T> = string | number | Array<T>
    
    export class MyClass<T> {
        model;
    
        constructor(model: [string, Val<T>][]) {
            this.model = new Map<string, Val<T>>(model);
        }
    }
    
    const c = new MyClass<string | number>([
        ["key1", "val1"],
        ["key2", 99],
        ["key3", ["cat", "dog"]],
        ["key4", [1,2,3]],
    ]);