Add utilities for working with ES6 Maps (#495)

Summary:
We’d like to like ES6 `Map`s, because they provide better type safety
than objects (primarily, `Map.prototype.get` has nullable result type).
However, the vanilla APIs are weak. Prominent problems are that `Map`s
always become `"{}"` under `JSON.stringify`, that there is no easy way
to convert between `Map`s and objects, and that there are no functions
to map over the keys and values of `Map`s.

In this commit, we add versions of those functions to a utility module.
The value-level implementations are straightforward, but these functions
nevertheless deserve a utility module because the types are somewhat
tricky to get right. The implementation requires casts through `any`,
and these should be written, analyzed, and proven correct just once. (In
particular, it would be easy to write an unsound type for `fromObject`.)

In a followup commit, we will amend existing portions of the codebase to
use these functions.

Test Plan:
Unit tests added; run `yarn travis`.

wchargin-branch: map-util
This commit is contained in:
William Chargin 2018-07-06 22:08:38 -07:00 committed by GitHub
parent daf2f9a376
commit 812b2d322e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 372 additions and 0 deletions

124
src/util/map.js Normal file
View File

@ -0,0 +1,124 @@
// @flow
/**
* Convert a string-keyed map to an object. Useful for conversion to
* JSON. If a map's keys are not strings, consider invoking `mapKeys`
* first.
*/
export function toObject<K: string, V, InK: K, InV: V>(
map: Map<InK, InV>
): {[K]: V} {
const result = {};
for (const [k, v] of map.entries()) {
result[k] = v;
}
return result;
}
/**
* Convert an object to a map. The resulting map will have key-value
* pairs corresponding to the enumerable own properties of the object in
* iteration order, as returned by `Object.keys`.
*/
export function fromObject<K, V, InK: K & string, InV: V>(object: {
[InK]: InV,
}): Map<K, V> {
const result = new Map();
const keys = (((Object.keys(object): string[]): any): InK[]);
for (const key of keys) {
result.set(key, object[key]);
}
return result;
}
/**
* Shallow-copy a map, allowing upcasting its type parameters.
*
* The `Map` type constructor is not covariant in its type parameters,
* which means that (e.g.) `Map<string, Dog>` is not a subtype of
* `Map<string, Animal>` even if `Dog` is a subtype of `Animal`. This is
* because, given a `Map<string, Animal>`, one can insert a `Cat`, which
* would break invariants of existing references to the variable as a
* map containing only `Dog`s.
*
* declare class Animal {};
* declare class Dog extends Animal {};
* declare class Cat extends Animal {};
* declare var dogMap: Map<string, Dog>;
* const animalMap: Map<string, Animal> = dogMap; // must fail
* animalMap.set("tabby", new Cat()); // or we could do this...
* (dogMap.values(): Iterator<Dog>); // ...now contains a `Cat`!
*
* This problem only exists when a map with existing references is
* mutated. Therefore, when we shallow-copy a map, we have the
* opportunity to upcast its type parameters: `copy(dogMap)` _can_ be a
* `Map<string, Animal>`.
*/
export function copy<K, V, InK: K, InV: V>(map: Map<InK, InV>): Map<K, V> {
const entries = map.entries();
return new Map((((entries: Iterator<[InK, InV]>): any): Iterator<[K, V]>));
}
/**
* Map across the keys of a map. Note that the key-mapping function is
* provided both the key and the value for each entry.
*
* The key-mapping function must be injective on the map's key set. If
* it maps two distinct input keys to the same output key, an error may
* be thrown.
*/
export function mapKeys<K, V, InK, InV: V>(
map: Map<InK, InV>,
f: (InK, InV) => K
): Map<K, V> {
const result = new Map();
for (const [k, v] of map.entries()) {
const outK = f(k, v);
if (result.has(outK)) {
throw new Error("duplicate key: " + String(outK));
}
result.set(outK, v);
}
return result;
}
/**
* Map across the values of a map. Note that the value-mapping function
* is provided both the key and the value for each entry.
*
* There are no restrictions on the value-mapping function (in
* particular, it need not be injective).
*/
export function mapValues<K, V, InK: K, InV>(
map: Map<InK, InV>,
g: (InK, InV) => V
): Map<K, V> {
const result = new Map();
for (const [k, v] of map.entries()) {
result.set(k, g(k, v));
}
return result;
}
/**
* Map simultaneously across the keys and values of a map.
*
* The key-mapping function must be injective on the map's key set. If
* it maps two distinct input keys to the same output key, an error may
* be thrown. There are no such restrictions on the value-mapping
* function.
*/
export function mapEntries<K, V, InK, InV>(
map: Map<InK, InV>,
h: (InK, InV) => [K, V]
): Map<K, V> {
const result = new Map();
for (const [k, v] of map.entries()) {
const [outK, outV] = h(k, v);
if (result.has(outK)) {
throw new Error("duplicate key: " + String(outK));
}
result.set(outK, outV);
}
return result;
}

248
src/util/map.test.js Normal file
View File

@ -0,0 +1,248 @@
// @flow
import * as MapUtil from "./map";
describe("util/map", () => {
describe("toObject", () => {
it("works on a map with string keys", () => {
const input: Map<string, number> = new Map().set("one", 1).set("two", 2);
const output: {[string]: number} = MapUtil.toObject(input);
expect(output).toEqual({one: 1, two: 2});
});
it("works on a map with keys a subtype of string", () => {
type Fruit = "APPLE" | "ORANGE";
const input: Map<Fruit, string> = new Map()
.set("APPLE", "good")
.set("ORANGE", "also good");
const output: {[Fruit]: string} = MapUtil.toObject(input);
expect(output).toEqual({APPLE: "good", ORANGE: "also good"});
});
it("statically rejects a map with keys not a subtype of string", () => {
const input: Map<number, string> = new Map()
.set(12, "not okay")
.set(13, "also not okay");
// $ExpectFlowError
MapUtil.toObject(input);
});
it("statically refuses to output an object with non-string keys", () => {
const input: Map<string, number> = new Map().set("one", 1).set("two", 2);
// $ExpectFlowError
const _: {[string | number]: number} = MapUtil.toObject(input);
});
it("allows upcasting the key and value types of the result object", () => {
type Fruit = "APPLE" | "ORANGE";
type Froot = "APPLE" | "ORANGE" | "BRICK";
const input: Map<Fruit, string> = new Map()
.set("APPLE", "good")
.set("ORANGE", "also good");
const _: {[Froot]: string | void} = MapUtil.toObject(input);
});
});
describe("fromObject", () => {
it("works on an object with string keys", () => {
const input: {[string]: number} = {one: 1, two: 2};
const output: Map<string, number> = MapUtil.fromObject(input);
expect(output).toEqual(new Map().set("one", 1).set("two", 2));
});
it("works on an object with keys a subtype of string", () => {
type Fruit = "APPLE" | "ORANGE";
const input: {[Fruit]: string} = {APPLE: "good", ORANGE: "also good"};
const output: Map<Fruit, string> = MapUtil.fromObject(input);
expect(output).toEqual(
new Map().set("APPLE", "good").set("ORANGE", "also good")
);
});
it("statically rejects a map with keys not a subtype of string", () => {
const input: {[number]: string} = {};
input[12] = "not okay";
input[13] = "also not okay";
// $ExpectFlowError
MapUtil.fromObject(input);
// If that were valid, then `(result.keys(): Iterator<number>)`
// would contain the strings "12" and "13".
});
it("allows upcasting the key and value types of the result map", () => {
type Fruit = "APPLE" | "ORANGE";
type Froot = "APPLE" | "ORANGE" | "BRICK";
const input: {[Fruit]: string} = {APPLE: "good", ORANGE: "also good"};
const output: Map<Froot, string | void> = MapUtil.fromObject(input);
expect(output).toEqual(
new Map().set("APPLE", "good").set("ORANGE", "also good")
);
});
it("allows upcasting the result map's key type to non-strings", () => {
// Contrast with `toObject`, where this is not and must not be
// permitted.
type Fruit = "APPLE" | "ORANGE";
type Froot = "APPLE" | "ORANGE" | "BRICK" | number;
const input: {[Fruit]: string} = {APPLE: "good", ORANGE: "also good"};
const output: Map<Froot, string> = MapUtil.fromObject(input);
expect(output).toEqual(
new Map().set("APPLE", "good").set("ORANGE", "also good")
);
});
});
describe("copy", () => {
it("returns a reference-distinct but value-equal map", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number, string> = MapUtil.copy(input);
expect(input).not.toBe(output);
expect(input).toEqual(output);
});
it("returns a map that is not linked to the original", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number, string> = MapUtil.copy(input);
input.set(3, "three");
output.set(4, "four");
expect(input).toEqual(
new Map()
.set(1, "one")
.set(2, "two")
.set(3, "three")
);
expect(output).toEqual(
new Map()
.set(1, "one")
.set(2, "two")
.set(4, "four")
);
});
it("allows upcasting the key and value types of the result map", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const _: Map<number | boolean, string | void> = MapUtil.copy(input);
});
});
describe("mapKeys", () => {
it("works in a simple case", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number, string> = MapUtil.mapKeys(input, (n) => n + 10);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(new Map().set(11, "one").set(12, "two"));
});
it("provides the corresponding value", () => {
const input: Map<number, string> = new Map()
.set(1, "one")
.set(2, "twooo");
const output: Map<number, string> = MapUtil.mapKeys(
input,
(n, s) => n + s.length
);
expect(input).toEqual(new Map().set(1, "one").set(2, "twooo"));
expect(output).toEqual(new Map().set(4, "one").set(7, "twooo"));
});
it("allows mapping to a different key type", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<string, string> = MapUtil.mapKeys(
input,
(n) => n + "!"
);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(new Map().set("1!", "one").set("2!", "two"));
});
it("allows upcasting the value type", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number, string | void> = MapUtil.mapKeys(
input,
(n) => n + 10
);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(new Map().set(11, "one").set(12, "two"));
});
it("throws on a non-injective key-mapping function", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
expect(() => MapUtil.mapKeys(input, (_) => 0)).toThrow(
"duplicate key: 0"
);
});
});
describe("mapValues", () => {
it("works in a simple case", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number, string> = MapUtil.mapValues(
input,
(_, s) => s + "!"
);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(new Map().set(1, "one!").set(2, "two!"));
});
it("provides the corresponding key", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number, string> = MapUtil.mapValues(
input,
(n, s) => s + "!".repeat(n)
);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(new Map().set(1, "one!").set(2, "two!!"));
});
it("allows mapping to a different value type", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number, string[]> = MapUtil.mapValues(input, (_, s) =>
s.split("")
);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(
new Map().set(1, ["o", "n", "e"]).set(2, ["t", "w", "o"])
);
});
it("allows upcasting the key type", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number | void, string> = MapUtil.mapValues(
input,
(_, n) => n + "!"
);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(new Map().set(1, "one!").set(2, "two!"));
});
it("permits a non-injective value-mapping function", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number, string> = MapUtil.mapValues(
input,
(_unused_key, _unused_value) => "wat"
);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(new Map().set(1, "wat").set(2, "wat"));
});
});
describe("mapEntries", () => {
it("works in a simple case", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number, string> = MapUtil.mapEntries(input, (n, s) => [
-n,
"negative " + s,
]);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(
new Map().set(-1, "negative one").set(-2, "negative two")
);
});
it("allows mapping to different key and value types", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<string, number> = MapUtil.mapEntries(input, (k, v) => [
v,
k,
]);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(new Map().set("one", 1).set("two", 2));
});
it("throws on a non-injective key-mapping function", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
expect(() => MapUtil.mapEntries(input, (_, v) => ["wat", v])).toThrow(
"duplicate key: wat"
);
});
it("permits a non-injective value-mapping function", () => {
const input: Map<number, string> = new Map().set(1, "one").set(2, "two");
const output: Map<number, string> = MapUtil.mapEntries(input, (k, _) => [
k + 10,
"wat",
]);
expect(input).toEqual(new Map().set(1, "one").set(2, "two"));
expect(output).toEqual(new Map().set(11, "wat").set(12, "wat"));
});
});
});