Can I create a map of key-value pairs by deserializing from JSON?
I'm trying to create a Map<TKey, TValue>
instance by deserializing a JSON document, but what I'm actually getting seems to be a different type of object, with none of the methods that Map<TKey, TValue>
has.
I'm quite new to TypeScript (I work mostly with C#) so I created a little unit test using Jest to check whether Map<TKey, TValue>
does what I want, specifically whether I can use the forEach
method to iterate through each of the key-value pairs.
function createMap(): Map<number, string> {
const map = new Map<number, string>();
map.set(1, 'foo');
map.set(2, 'bar');
console.log(map); // Console output: Map(2) { 1 => 'foo', 2 => 'bar' }
return map;
}
function iterateUsingForEach(map: Map<number, string>): number {
let counter = 0;
map.forEach((value: string, key: number, map: Map<number, string>) => {
// Normally I'd do something more exciting here than just count the
// elements, but in the interests of simplicity...
counter++;
});
return counter;
}
describe('A Map instantiated using its constructor', () => {
it('can be iterated using map.forEach()', () => {
const map = createMap();
const count = iterateUsingForEach(map);
expect(count).toBe(2); // pass
});
});
So far so good, the test passes, and I have a list of key-value pairs and can iterate over each of them, which is what I want. But the data to populate this object comes from a call to a web API, which returns a JSON document, which I need to deserialize to a Map<number, string>
, so I added some more tests to simulate this use case:
function deserializeMap(): Map<number, string> {
const json = '{ "1": "foo", "2": "bar" }';
const map: Map<number, string> = JSON.parse(json) /*as Map<number, string>*/;
console.log(map); // Console output: { '1': 'foo', '2': 'bar' }
return map;
}
function iterateUsingObjectDotEntries(map: Map<number, string>): number {
let count = 0;
for (let [key, value] of Object.entries(map)) {
count++;
}
return count;
}
describe('A Map instantiated using deserialization', () => {
it('can be iterated using map.forEach()', () => {
const map = deserializeMap();
const count = iterateUsingForEach(map); // fail - TypeError: map.forEach is not a function
expect(count).toBe(2);
});
it('can be iterated using Object.entries(map)', () => {
const map = deserializeMap();
const count = iterateUsingObjectDotEntries(map);
expect(count).toBe(2); // pass
});
});
The first test fails because map.forEach is not a function
, and looking at the console output, it seems that this time map
isn't actually a Map<number, string>
instance at all, but a plain object with properties called 1
and 2
, which probably has no prototype and therefore no methods, despite the fact that the deserializeMap
function explicitly declares its return type as Map<number, string>
and even tries to cast its return value to that type (I'm not sure whether that cast even has any effect - edit as per jcalz's comment I've commented that "cast" out as it's not actually a cast but a type assertion).
I'm guessing that the type checking performed by the TypeScript compiler isn't complaining about this because map
is still type compatible with Map<number, string>
even though it's not actually an instance of that type?
The second test passes because it's not treating the map
object as a list of key-value pairs, instead it's iterating through each of the property names and values. Functionally, using Object.entries(map)
does what I want, but it strikes me as being analogous to using .net's Reflection
to discover the names and values of the properties of an object at runtime, so I was wondering whether this would have an impact on performance. So I added this benchmark to compare the performance of the two approaches:
// Requires the tinybench package, to install it run:
// npm install tinybench --save-dev
// Also requires the following import:
// import { Bench } from 'tinybench';
it('runs a benchmark to compare the performance of the two approaches', async () => {
const bench = new Bench({ iterations: 10000 });
const instantiated = createMap();
const deserialized = deserializeMap();
bench
.add('map.forEach()', () => {
iterateUsingForEach(instantiated);
})
.add('Object.entries(map)', () => {
iterateUsingObjectDotEntries(deserialized);
});
await bench.run();
console.table(bench.table());
});
I've run this quite a few times and the results vary, but Object.entries(map)
seems to take between 1.5 and 2 times as long as map.forEach()
to do the same job. Example output:
┌─────────┬───────────────────────┬─────────────┬───────────────────┬──────────┬─────────┐
│ (index) │ Task Name │ ops/sec │ Average Time (ns) │ Margin │ Samples │
├─────────┼───────────────────────┼─────────────┼───────────────────┼──────────┼─────────┤
│ 0 │ 'map.forEach()' │ '1,739,642' │ 574.8305975247757 │ '±8.27%' │ 869822 │
│ 1 │ 'Object.entries(map)' │ '1,074,330' │ 930.8122055151725 │ '±1.02%' │ 537167 │
└─────────┴───────────────────────┴─────────────┴───────────────────┴──────────┴─────────┘
So my question is, can I deserialize the JSON document to an actual instance of Map<number, string>
, complete with its prototype and methods, rather than an object which is just type compatible with that type, in order to take advantage of the faster performance of map.forEach()
?
Edit: I don't think this is a duplicate of cast data received from backend to a frontend interface in typescript because that seems to be about mapping the properties of an object with one shape to the properties of a different object with a different shape, whereas I'm trying to create an object from JSON which has a prototype and methods.
Comments
Post a Comment