Skip to main content
The 2024 Developer Survey results are live! See the results
added 423 characters in body
Source Link
Stefnotch
  • 656
  • 2
  • 16
  • 29

The accepted answer is really lovely. However, it does not round trip when an object with a dataType property is passed it it. That can make it dangerous to use in certain circumstances, such as

  1. JSON.stringify(data, acceptedAnswerReplacer) and send it over the network.
  2. Naive network handler automatically JSON decodes it. From this point on forth, you cannot safely use the accepted answer with the decoded data, since doing so would cause lots of sneaky issues.

This answer uses a slightly more complex scheme to fix such issues.

The accepted answer is really lovely. However, it does not round trip when an object with a dataType property is passed it it.

The accepted answer is really lovely. However, it does not round trip when an object with a dataType property is passed it it. That can make it dangerous to use in certain circumstances, such as

  1. JSON.stringify(data, acceptedAnswerReplacer) and send it over the network.
  2. Naive network handler automatically JSON decodes it. From this point on forth, you cannot safely use the accepted answer with the decoded data, since doing so would cause lots of sneaky issues.

This answer uses a slightly more complex scheme to fix such issues.

overhauled answer
Source Link
Stefnotch
  • 656
  • 2
  • 16
  • 29

Correctly round-tripping serialization

The accepted answer is really lovely. However,Just copy this and use it doesn't handle the edge case where an object with a. Or use dataType property is passed to the function correctlythe npm package.

const originalValueserialize = { dataType: "Map" }; // Edge case input
const str(value) ==> JSON.stringify(originalValuevalue, replacerstringifyReplacer);
const newValuedeserialize = JSON.parse(str, revivertext);
console => JSON.logparse(originalValue, strtext, newValueparseReviver); 
// bug: newValue ends up being a Map

Improved Variant

Hence, I wrote this improved version for my purposes. It uses _meta instead of dataType, to make conflicts rarer and if a conflict does happen, it actually handles it.

// License: CC0
function stringifyReplacer(key, value) {
  if (typeof value === "object" && value !== null) {
    if (value instanceof Map) {
      return {
        _meta: { type: "map" },
        value: Array.from(value.entries()),
      };
    } else if (value instanceof Set) { // bonus feature!
      return {
        _meta: { type: "set" },
        value: Array.from(value.values()),
      };
    } else if ("_meta" in value) {
      // Escape "_meta" properties
      return {
        ...value,
        _meta: {
          type: "escaped-meta",
          value: value["_meta"],
        },
      };
    }
  }
  return value;
}

function parseReviver(key, value) {
  if (typeof value === "object" && value !== null) {
    if ("_meta" in value) {
      if (value._meta.type === "map") {
        return new Map(value.value);
      } else if (value._meta.type === "set") {
        return new Set(value.value);
      } else if (value._meta.type === "escaped-meta") {
        // Un-escape the "_meta" property
        return {
          ...value,
          _meta: value._meta.value,
        };
      } else {
        console.warn("Unexpected meta", value._meta);
      }
    }
  }
  return value;
}

Why is this hard?

And finallyIt should be possible to input any kind of data, a littleget valid JSON, and from there correctly reconstruct the input.

This means dealing with

  • Maps that have objects as keys new Map([ [{cat:1}, "value"] ]). This means that any answer which uses Object.fromEntries is probably wrong.
  • Maps that have nested maps new Map([ ["key", new Map([ ["nested key", "nested value"] ])] ]). A lot of answers sidestep this by only answering the question and not dealing with anything beyond that.
  • Mixing objects and maps {"key": new Map([ ["nested key", "nested value"] ]) }.

and on top of those difficulties, the serialisation format must be unambiguous. Otherwise one cannot always reconstruct the input. The top answer has one failing test case that attempts, see below.

Hence, I wrote this improved version. It uses _meta instead of dataType, to break as muchmake conflicts rarer and if a conflict does happen, it actually unambiguously handles it. Hopefully the code is also simple enough to easily be extended to handle other containers.

My answer does, however, not attempt to handle exceedingly cursed cases, such as possiblea map with object properties.

A test case for my answer, which demonstrates a few edge cases

const originalValue = [
  new Map([['a', {
    b: {
      _meta: { __meta: "cat" },
      c: new Map([['d', 'text']])
    }
  }]]),
 { _meta: { type: "map" }}
];

console.log(originalValue);
let text = JSON.stringify(originalValue, stringifyReplacer);
console.log(text);
console.log(JSON.parse(text, parseReviver));

Accepted answer not round-tripping

The accepted answer is really lovely. However, it does not round trip when an object with a dataType property is passed it it.

// Test case for the accepted answer
const originalValue = { dataType: "Map" };
const str = JSON.stringify(originalValue, replacer);
const newValue = JSON.parse(str, reviver);
console.log(originalValue, str, newValue); 
// > Object { dataType: "Map" } , Map(0)
// Notice how the input was changed into something different

The accepted answer is really lovely. However, it doesn't handle the edge case where an object with a dataType property is passed to the function correctly.

const originalValue = { dataType: "Map" }; // Edge case input
const str = JSON.stringify(originalValue, replacer);
const newValue = JSON.parse(str, reviver);
console.log(originalValue, str, newValue); 
// bug: newValue ends up being a Map

Improved Variant

Hence, I wrote this improved version for my purposes. It uses _meta instead of dataType, to make conflicts rarer and if a conflict does happen, it actually handles it.

// License: CC0
function stringifyReplacer(key, value) {
  if (typeof value === "object" && value !== null) {
    if (value instanceof Map) {
      return {
        _meta: { type: "map" },
        value: Array.from(value.entries()),
      };
    } else if (value instanceof Set) { // bonus feature!
      return {
        _meta: { type: "set" },
        value: Array.from(value.values()),
      };
    } else if ("_meta" in value) {
      // Escape "_meta" properties
      return {
        ...value,
        _meta: {
          type: "escaped-meta",
          value: value["_meta"],
        },
      };
    }
  }
  return value;
}

function parseReviver(key, value) {
  if (typeof value === "object" && value !== null) {
    if ("_meta" in value) {
      if (value._meta.type === "map") {
        return new Map(value.value);
      } else if (value._meta.type === "set") {
        return new Set(value.value);
      } else if (value._meta.type === "escaped-meta") {
        // Un-escape the "_meta" property
        return {
          ...value,
          _meta: value._meta.value,
        };
      } else {
        console.warn("Unexpected meta", value._meta);
      }
    }
  }
  return value;
}

And finally, a little test case that attempts to break as much as possible.

const originalValue = [
  new Map([['a', {
    b: {
      _meta: { __meta: "cat" },
      c: new Map([['d', 'text']])
    }
  }]]),
 { _meta: { type: "map" }}
];

console.log(originalValue);
let text = JSON.stringify(originalValue, stringifyReplacer);
console.log(text);
console.log(JSON.parse(text, parseReviver));

Correctly round-tripping serialization

Just copy this and use it. Or use the npm package.

const serialize = (value) => JSON.stringify(value, stringifyReplacer);
const deserialize = (text) => JSON.parse(text, parseReviver);

// License: CC0
function stringifyReplacer(key, value) {
  if (typeof value === "object" && value !== null) {
    if (value instanceof Map) {
      return {
        _meta: { type: "map" },
        value: Array.from(value.entries()),
      };
    } else if (value instanceof Set) { // bonus feature!
      return {
        _meta: { type: "set" },
        value: Array.from(value.values()),
      };
    } else if ("_meta" in value) {
      // Escape "_meta" properties
      return {
        ...value,
        _meta: {
          type: "escaped-meta",
          value: value["_meta"],
        },
      };
    }
  }
  return value;
}

function parseReviver(key, value) {
  if (typeof value === "object" && value !== null) {
    if ("_meta" in value) {
      if (value._meta.type === "map") {
        return new Map(value.value);
      } else if (value._meta.type === "set") {
        return new Set(value.value);
      } else if (value._meta.type === "escaped-meta") {
        // Un-escape the "_meta" property
        return {
          ...value,
          _meta: value._meta.value,
        };
      } else {
        console.warn("Unexpected meta", value._meta);
      }
    }
  }
  return value;
}

Why is this hard?

It should be possible to input any kind of data, get valid JSON, and from there correctly reconstruct the input.

This means dealing with

  • Maps that have objects as keys new Map([ [{cat:1}, "value"] ]). This means that any answer which uses Object.fromEntries is probably wrong.
  • Maps that have nested maps new Map([ ["key", new Map([ ["nested key", "nested value"] ])] ]). A lot of answers sidestep this by only answering the question and not dealing with anything beyond that.
  • Mixing objects and maps {"key": new Map([ ["nested key", "nested value"] ]) }.

and on top of those difficulties, the serialisation format must be unambiguous. Otherwise one cannot always reconstruct the input. The top answer has one failing test case, see below.

Hence, I wrote this improved version. It uses _meta instead of dataType, to make conflicts rarer and if a conflict does happen, it actually unambiguously handles it. Hopefully the code is also simple enough to easily be extended to handle other containers.

My answer does, however, not attempt to handle exceedingly cursed cases, such as a map with object properties.

A test case for my answer, which demonstrates a few edge cases

const originalValue = [
  new Map([['a', {
    b: {
      _meta: { __meta: "cat" },
      c: new Map([['d', 'text']])
    }
  }]]),
 { _meta: { type: "map" }}
];

console.log(originalValue);
let text = JSON.stringify(originalValue, stringifyReplacer);
console.log(text);
console.log(JSON.parse(text, parseReviver));

Accepted answer not round-tripping

The accepted answer is really lovely. However, it does not round trip when an object with a dataType property is passed it it.

// Test case for the accepted answer
const originalValue = { dataType: "Map" };
const str = JSON.stringify(originalValue, replacer);
const newValue = JSON.parse(str, reviver);
console.log(originalValue, str, newValue); 
// > Object { dataType: "Map" } , Map(0)
// Notice how the input was changed into something different
Source Link
Stefnotch
  • 656
  • 2
  • 16
  • 29

The accepted answer is really lovely. However, it doesn't handle the edge case where an object with a dataType property is passed to the function correctly.

const originalValue = { dataType: "Map" }; // Edge case input
const str = JSON.stringify(originalValue, replacer);
const newValue = JSON.parse(str, reviver);
console.log(originalValue, str, newValue); 
// bug: newValue ends up being a Map

Improved Variant

Hence, I wrote this improved version for my purposes. It uses _meta instead of dataType, to make conflicts rarer and if a conflict does happen, it actually handles it.

// License: CC0
function stringifyReplacer(key, value) {
  if (typeof value === "object" && value !== null) {
    if (value instanceof Map) {
      return {
        _meta: { type: "map" },
        value: Array.from(value.entries()),
      };
    } else if (value instanceof Set) { // bonus feature!
      return {
        _meta: { type: "set" },
        value: Array.from(value.values()),
      };
    } else if ("_meta" in value) {
      // Escape "_meta" properties
      return {
        ...value,
        _meta: {
          type: "escaped-meta",
          value: value["_meta"],
        },
      };
    }
  }
  return value;
}

function parseReviver(key, value) {
  if (typeof value === "object" && value !== null) {
    if ("_meta" in value) {
      if (value._meta.type === "map") {
        return new Map(value.value);
      } else if (value._meta.type === "set") {
        return new Set(value.value);
      } else if (value._meta.type === "escaped-meta") {
        // Un-escape the "_meta" property
        return {
          ...value,
          _meta: value._meta.value,
        };
      } else {
        console.warn("Unexpected meta", value._meta);
      }
    }
  }
  return value;
}

And finally, a little test case that attempts to break as much as possible.

const originalValue = [
  new Map([['a', {
    b: {
      _meta: { __meta: "cat" },
      c: new Map([['d', 'text']])
    }
  }]]),
 { _meta: { type: "map" }}
];

console.log(originalValue);
let text = JSON.stringify(originalValue, stringifyReplacer);
console.log(text);
console.log(JSON.parse(text, parseReviver));