Search code examples
arraystypescriptdictionarycastingtype-inference

In Typescript, how can I convert an Array<T> to a Map<K, V> and infer K and V if T is a tuple [K, V] while having compile time protection if it isn't


The question in the title pretty much says it all. The catch is that T cannot be restricted.

Here is what I have tried:

class ArrayWrapper<T> {
  constructor(private arr: T[]) {}

  toMap<K, V>() {
    return new Map(<T extends [K, V] ? [K, V][] : never>this.arr);
  }
}

I have also tried this:

class ArrayWrapper<T> {
  constructor(private arr: T[]) {}

  toMap() {
    return new Map(<T extends [infer K, infer V] ? [K, V][] : never>this.arr);
  }
}

In both of the above cases the compiler is giving me a "Conversion of type 'T[]' to ... may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first" error.

So I tried something different and instead of never, I changed the false branch of the conditional type to never[]. And this got rid of the compilation error but now when I do something like:

class ArrayWrapper<T> {
  constructor(private arr: T[]) {}

  toMap() {
    return new Map(<T extends [infer K, infer V] ? [K, V][] : never[]>this.arr);
  }
}

const notConvertibleToMap = [1, 2, 3];
const convertibleToMap: [number, string][] = [[1, 'one'], [2, 'two'], [3, 'three']];

const arr1 = new ArrayWrapper(notConvertibleToMap);
const map1 = arr1.toMap();

const arr2 = new ArrayWrapper(convertibleToMap);
const map2 = arr2.toMap();

The compiler infers Map<unknown, unknown> for both map1 and map2.

What I would like is for the compiler to:

  • in the case of map1, when trying to call toMap() when T is not [K,V] either give an error or at the very least maybe infer a return type of never since this function call is going to throw an error because T is not a tuple and the Map constructor is going to throw.
  • in the case of map2, where T IS a [K, V], I want the compiler to correctly infer a type of Map<K, V> for map2.

Like I said at the beginning, obviously restricting the type of T in the class like this

class ArrayWrapper<T extends [K, V], K, V> {
  constructor(private arr: T[]) {}

  toMap() {
    return new Map(this.arr);
  }
}

would allow for proper inference of K and V, but I don't want to restrict T. I need this class to accept arrays of any type of contained value T.

Additional attempt was made with this:

class ArrayWrapper<T> {
  constructor(private arr: T[]) {}

  toMap() {
    if (this.isArrayOfTuple(this.arr)) {
      return new Map(this.arr);
    }

    throw new Error("called toMap() on ArrayWrapper that is not an array of tuples");    
  }

  isArrayOfTuple<K, V>(arr: T[]): arr is [K, V][] {
    return arr[0] instanceof Array && arr[0].length === 2;
  }
}

but unfortunately the compiler is complaining over the return type of isArrayOfTuple, saying that

A type predicate's type must be assignable to its parameter's type.
  Type '[K, V][]' is not assignable to type 'T[]'.
    Type '[K, V]' is not assignable to type 'T'.
      'T' could be instantiated with an arbitrary type which could be unrelated to '[K, V]'

Solution

  • If you want the compiler to make calling toMap() an error if T isn't assignable to [K, V] for some K and V, then in some sense it doesn't matter what the output type is in such a case. It could be Map<unknown, unknown> or Map<never, never> or anything, as long as the toMap() call is a compiler error. I think you'll end up with a runtime error (you can wade through the spec if you really care) so the function won't return... the "actual" return type is never which can be safely widened to Map<unknown, unknown> or anything you want without causing a type safety issue.

    Anyway, to make the compiler error happen, you can give toMap() a this parameter which requires this be of ArrayWrapper<[any, any]> or something equivalent. You could use conditional type inference to manually infer K and V from T:

    toMap(this: ArrayWrapper<[any, any]>) {
        type K = T extends [infer K, any] ? K : never;
        type V = T extends [any, infer V] ? V : never;
        return new Map<K, V>(this.arr);
    }
    

    but it's even easier to make toMap() a generic method and have K and V inferred automatically:

    toMap<K, V>(this: ArrayWrapper<[K, V]>) {
        return new Map<K, V>(this.arr);
    }
    

    Either method will give you the behavior you're looking for:

    const arr1 = new ArrayWrapper(notConvertibleToMap);
    const map1 = arr1.toMap(); // <-- compiler error
    // --------> ~~~~
    // The 'this' context of type 'ArrayWrapper<number>' is not assignable to method's 
    // 'this' of type 'ArrayWrapper<[unknown, unknown]>'.
    
    const arr2 = new ArrayWrapper(convertibleToMap);
    const map2 = arr2.toMap(); // okay
    // const map2: Map<number, string>
    

    Playground link to code