9

I would like to use Map instead of object map to declare some keys and values. But Typescript doesn't seem to support index types for ES6 Map, is that correct and are there any workarounds?

Additionally, I would like to make the values type-safe as well so that each entry in the map has the correct type for the value corresponding to the key.

Here is some pseudo-code that describes what I am trying to achieve:

type Keys = 'key1' | 'key2';

type  Values = {
  'key1': string;
  'key2': number;
}

/** Should display missing entry error */
const myMap = new Map<K in Keys, Values[K]>([
  ['key1', 'error missing key'],
]);

/** Should display wrong value type error for 'key2' */
const myMap = new Map<K in Keys, Values[K]>([
  ['key1', 'okay'],
  ['key2', 'error: this value should be number'],
]);

/** Should pass */
const myMap = new Map<K in Keys, Values[K]>([
  ['key1', 'all good'],
  ['key2', 42],
]);

Edit: more code that partially describes my use case

enum Types = {
  ADD = 'ADD',
  REMOVE = 'REMOVE',
};

/** I would like type-safety and autocompletion for the payload parameter */
const handleAdd = (state, payload) => ({...state, payload});

/** I would like to ensure that all types declared in Types are implemented */
export const reducers = new Map([
  [Types.ADD, handleAdd],
  [Types.REMOVE, handleRemove]
]);
jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 4
    `Map` isn't really typed in such a way to make this work for you. You'd need to come up with your own custom typings `interface MyMap {...}` and `interface MyMapConstructor {...}` which may or may not be actual subtypes of `Map` and `MapConstructor`. That's an awful lot of work for something that, when you're all done, gives you the same functionality as your `Values` type. How much do you need this? – jcalz Feb 27 '19 at 13:59
  • Edit: Added more code to the original post. I prefer the above structure rather than objects because I am considering using Symbols as the keys, and because it looks more structured/strict. – Nikolai Hegelstad Feb 27 '19 at 14:17
  • The first example doesn't seem to be an error to me – Ruan Mendes Feb 27 '19 at 14:17
  • Correct Juan, my first use-case is actually solved. I have also thought of declaring these key, values as Arrays with strict type-checking and then have less strict type-checking on the Maps, but I am not sure yet if it will work. – Nikolai Hegelstad Feb 27 '19 at 14:24
  • Can't objects have `Symbol` keys too? And with your `enum` example you can have `const reducers = {[Types.ADD]: handleAdd, [Types.REMOVE]: handleRemove}`. – jcalz Feb 27 '19 at 14:25
  • Object mappings are from `string -> object` as far as I know. For the reducers, I would like the parameters of the functions to infer which type they will receive as the payload. My actions are FSA compliant with type and payload as properties. – Nikolai Hegelstad Feb 27 '19 at 14:27
  • 1
    Symbols can be object keys: https://www.typescriptlang.org/docs/handbook/symbols.html – jcalz Feb 27 '19 at 14:32

1 Answers1

8

Here's the closest I can imagine getting, although I still don't understand why we don't just use plain objects to begin with:

type ObjectToEntries<O extends object> = { [K in keyof O]: [K, O[K]] }[keyof O]

interface ObjectMap<O extends object> {
  forEach(callbackfn: <K extends keyof O>(
    value: O[K], key: K, map: ObjectMap<O>
  ) => void, thisArg?: any): void;
  get<K extends keyof O>(key: K): O[K];
  set<K extends keyof O>(key: K, value: O[K]): this;
  readonly size: number;
  [Symbol.iterator](): IterableIterator<ObjectToEntries<O>>;
  entries(): IterableIterator<ObjectToEntries<O>>;
  keys(): IterableIterator<keyof O>;
  values(): IterableIterator<O[keyof O]>;
  readonly [Symbol.toStringTag]: string;
}

interface ObjectMapConstructor {
  new <E extends Array<[K, any]>, K extends keyof any>(
    entries: E
  ): ObjectMap<{ [P in E[0][0]]: Extract<E[number], [P, any]>[1] }>;
  new <T>(): ObjectMap<Partial<T>>;
  readonly prototype: ObjectMap<any>;
}

const ObjectMap = Map as ObjectMapConstructor;

The idea is to make a new interface, ObjectMap, which is specifically dependent on an object type O to determine its key/value relationship. And then you can say that the Map constructor can act as an ObjectMap constructor. I also removed any methods that can change which keys are actually present (and the has() method is redundantly true also).

I can go through the trouble of explaining each method and property definition, but it's a lot of type-juggling. In short you want to use K extends keyof O and O[K] to represent the types normally represented by K and V in Map<K, V>.

The constructor is a bit more annoying in that type inference doesn't work the way you'd like, so guaranteeing type safety comes in two steps:

// let the compiler infer the type returned by the constructor
const myMapInferredType = new ObjectMap([
  ['key1', 'v'], 
  ['key2', 1],  
]);

// make sure it's assignable to `ObjectMap<Values>`: 
const myMap: ObjectMap<Values> = myMapInferredType;

If your myMapInferredType doesn't match ObjectMap<Values> (e.g., you are missing keys or have the wrong value types) then myMap will give you errors.

Now you can use myMap as an ObjectMap<Values>, similarly to how you'd use a Map instance, with get() and set(), and it should be type safe.

Please note again... this seems like a lot of work for a more complex object with trickier typings and no more functionality than a plain object. I would seriously warn anyone using a Map whose keys are subtypes of keyof any (that is, string | number | symbol) to strongly consider using a plain object instead, and be sure that your use case really necessitates a Map.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks for taking giving of your time to explain this is such great detail. It works as it stands now, but I will have to consider if it is worth to use over a plain object, especially since I likely have to cast key as key. – Nikolai Hegelstad Feb 27 '19 at 14:59
  • Then I assume this would be a feasible option, especially because Map ensures order of insertion. – Nikolai Hegelstad Feb 27 '19 at 15:02
  • You care about order of insertion? `ObjectMap` doesn't know about the order of keys in `O` so it won't help you with that at compile time. Not sure why you care about order exactly but I could imagine a type that does guarantee orders with tuple types instead of arrays... but this is even more complex and I think I need to run away screaming now. ‍♂️ – jcalz Feb 27 '19 at 15:06
  • Hahaha, well, now that I think of it, I may have startled you needlessly Insertion order isn't important as it is a map, so each key can only be represented once. – Nikolai Hegelstad Feb 27 '19 at 15:14
  • 2
    @jcalz Did anything change in TypeScript since then? I would also really love to be able to use Map, because I need to be able to sort properties. – Łukasz Zaroda Aug 03 '22 at 16:36
  • @ŁukaszZaroda Nothing has changed that I know of. I'm not sure what you mean by "sort" here. Depending on your use case it may be possible to write custom typings for it, but a comment section isn't a great place to explore that. You might want to make your own question post for it. – jcalz Aug 03 '22 at 18:15
  • @jcalz I meant that if order of keys matter for someone, he is kind of stuck with Map, because it actually handles that (basic js objects don't), and it's a shame that we are losing some typings when we convert our objects to Maps. No questions yet :) . – Łukasz Zaroda Aug 03 '22 at 19:28