2023-11-05

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.



No comments:

Post a Comment