From 812b2d322e1ca8059af092a3494f1c4b37205e00 Mon Sep 17 00:00:00 2001 From: William Chargin Date: Fri, 6 Jul 2018 22:08:38 -0700 Subject: [PATCH] Add utilities for working with ES6 Maps (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/util/map.js | 124 ++++++++++++++++++++++ src/util/map.test.js | 248 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 src/util/map.js create mode 100644 src/util/map.test.js diff --git a/src/util/map.js b/src/util/map.js new file mode 100644 index 0000000..faf318c --- /dev/null +++ b/src/util/map.js @@ -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( + map: Map +): {[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(object: { + [InK]: InV, +}): Map { + 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` is not a subtype of + * `Map` even if `Dog` is a subtype of `Animal`. This is + * because, given a `Map`, 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; + * const animalMap: Map = dogMap; // must fail + * animalMap.set("tabby", new Cat()); // or we could do this... + * (dogMap.values(): Iterator); // ...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`. + */ +export function copy(map: Map): Map { + 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( + map: Map, + f: (InK, InV) => K +): Map { + 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( + map: Map, + g: (InK, InV) => V +): Map { + 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( + map: Map, + h: (InK, InV) => [K, V] +): Map { + 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; +} diff --git a/src/util/map.test.js b/src/util/map.test.js new file mode 100644 index 0000000..05dde90 --- /dev/null +++ b/src/util/map.test.js @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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)` + // 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 = 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 = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = new Map().set(1, "one").set(2, "two"); + const _: Map = MapUtil.copy(input); + }); + }); + + describe("mapKeys", () => { + it("works in a simple case", () => { + const input: Map = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = new Map() + .set(1, "one") + .set(2, "twooo"); + const output: Map = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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 = 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 = new Map().set(1, "one").set(2, "two"); + const output: Map = 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")); + }); + }); +});