Skip to content

Commit

Permalink
Retain null JSON properties as undefined when deserializing (#705)
Browse files Browse the repository at this point in the history
  • Loading branch information
lyonsil authored Jan 11, 2024
1 parent d8ec89f commit a1d26a9
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 79 deletions.
43 changes: 19 additions & 24 deletions lib/papi-dts/papi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,21 +659,18 @@ declare module 'shared/utils/papi-util' {
*/
export function deepEqual(a: unknown, b: unknown): boolean;
/**
* Converts a JavaScript value to a JSON string, changing `undefined` properties to `null`
* properties in the JSON string.
* Converts a JavaScript value to a JSON string, changing `undefined` properties in the JavaScript
* object to `null` properties in the JSON string.
*
* WARNING: `null` and `undefined` values are treated as the same thing by this function and will be
* dropped when passed to {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will
* become `{ a: 1 }` after passing through {@link serialize} then {@link deserialize}. If you are
* passing around user data that needs to retain `null` and/or `undefined` values, you should wrap
* them yourself in a string before using this function. Alternatively, you can write your own
* replacer that will preserve `null` and `undefined` values in a way that a custom reviver will
* understand when deserializing.
* WARNING: `null` values will become `undefined` values after passing through {@link serialize} then
* {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will become `{ a: 1, b:
* undefined, c: undefined }`. If you are passing around user data that needs to retain `null`
* values, you should wrap them yourself in a string before using this function. Alternatively, you
* can write your own replacer that will preserve `null` in a way that you can recover later.
*
* @param value A JavaScript value, usually an object or array, to be converted.
* @param replacer A function that transforms the results. Note that all `null` and `undefined`
* values returned by the replacer will be further transformed into a moniker that deserializes
* into `undefined`.
* @param replacer A function that transforms the results. Note that all `undefined` values returned
* by the replacer will be further transformed into `null` in the JSON string.
* @param space Adds indentation, white space, and line break characters to the return-value JSON
* text to make it easier to read. See the `space` parameter of `JSON.stringify` for more
* details.
Expand All @@ -684,21 +681,20 @@ declare module 'shared/utils/papi-util' {
space?: string | number,
): string;
/**
* Converts a JSON string into a value.
* Converts a JSON string into a value, converting all `null` properties from JSON into `undefined`
* in the returned JavaScript value/object.
*
* WARNING: `null` and `undefined` values that were serialized by {@link serialize} will both be made
* into `undefined` values by this function. If those values are properties of objects, those
* properties will simply be dropped. For example, `{ a: 1, b: undefined, c: null }` will become `{
* a: 1 }` after passing through {@link serialize} then {@link deserialize}. If you are passing around
* user data that needs to retain `null` and/or `undefined` values, you should wrap them yourself in
* a string before using this function. Alternatively, you can write your own reviver that will
* preserve `null` and `undefined` values in a way that a custom replacer will encode when
* serializing.
* WARNING: `null` values will become `undefined` values after passing through {@link serialize} then
* {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will become `{ a: 1, b:
* undefined, c: undefined }`. If you are passing around user data that needs to retain `null`
* values, you should wrap them yourself in a string before using this function. Alternatively, you
* can write your own replacer that will preserve `null` in a way that you can recover later.
*
* @param text A valid JSON string.
* @param reviver A function that transforms the results. This function is called for each member of
* the object. If a member contains nested objects, the nested objects are transformed before the
* parent object is.
* parent object is. Note that `null` values are converted into `undefined` values after the
* reviver has run.
*/
export function deserialize(
value: string,
Expand All @@ -711,8 +707,7 @@ declare module 'shared/utils/papi-util' {
* @returns True if serializable; false otherwise
*
* Note: the values `undefined` and `null` are serializable (on their own or in an array), but
* `undefined` and `null` properties of objects are dropped when serializing/deserializing. That
* means `undefined` and `null` properties on a value passed in will cause it to fail.
* `null` values get transformed into `undefined` when serializing/deserializing.
*
* WARNING: This is inefficient right now as it stringifies, parses, stringifies, and === the value.
* Please only use this if you need to
Expand Down
46 changes: 26 additions & 20 deletions src/shared/utils/papi-util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,20 +219,28 @@ describe('PAPI Util Functions: serialize and deserialize', () => {
expect(deepEqual(deserialize('5'), JSON.parse('5'))).toBeTruthy();
expect(serialize('X')).toEqual(JSON.stringify('X'));
expect(deepEqual(deserialize('"X"'), JSON.parse('"X"'))).toBeTruthy();
expect(serialize([3, 5, 7])).toEqual(JSON.stringify([3, 5, 7]));
expect(deepEqual(deserialize('[3,5,7]'), JSON.parse('[3,5,7]'))).toBeTruthy();
});
it('exclusively use null in JSON strings and exclusively uses undefined in JS objects', () => {
const testObject = { foo: 'fooValue', bar: undefined, baz: null };
expect(serialize(testObject)).toEqual(
JSON.stringify({ foo: 'fooValue', bar: null, baz: null }),
);
expect(deepEqual({ foo: 'fooValue' }, deserialize(serialize(testObject)))).toBeTruthy();
expect(deepEqual({ foo: 'fooValue' }, deserialize(JSON.stringify(testObject)))).toBeTruthy();
expect(serialize(true)).toEqual(JSON.stringify(true));
expect(deepEqual(deserialize('true'), JSON.parse('true'))).toBeTruthy();
expect(serialize(false)).toEqual(JSON.stringify(false));
expect(deepEqual(deserialize('false'), JSON.parse('false'))).toBeTruthy();
expect(serialize([0, 3, 5, 7])).toEqual(JSON.stringify([0, 3, 5, 7]));
expect(deepEqual(deserialize('[0,3,5,7]'), JSON.parse('[0,3,5,7]'))).toBeTruthy();
expect(serialize({ 1: 'one' })).toEqual(JSON.stringify({ 1: 'one' }));
expect(deepEqual(deserialize('{"1":"one"}'), JSON.parse('{"1":"one"}'))).toBeTruthy();
});
it('exclusively use null in JSON strings and exclusively use undefined in JS objects', () => {
const testObject = { foo: 'val', bar: undefined, baz: null };
expect(serialize(testObject)).toEqual(JSON.stringify({ foo: 'val', bar: null, baz: null }));
expect(
deepEqual({ foo: 'val', bar: undefined, baz: undefined }, deserialize(serialize(testObject))),
).toBeTruthy();
expect(
deepEqual({ foo: 'val', baz: undefined }, deserialize(JSON.stringify(testObject))),
).toBeTruthy();
});
it('handle deeply nested null/undefined values', () => {
const deepNesting = { a: { b: { c: { d: { e: 'something', undef: undefined, nil: null } } } } };
const roundTrip = { a: { b: { c: { d: { e: 'something' } } } } };
const deepNesting = { a: { b: { c: { d: 'something', undef: undefined, nil: null } } } };
const roundTrip = { a: { b: { c: { d: 'something', undef: undefined, nil: undefined } } } };
expect(deepEqual(roundTrip, deserialize(serialize(deepNesting)))).toBeTruthy();
});
it('work with custom replacers/revivers', () => {
Expand All @@ -243,14 +251,14 @@ describe('PAPI Util Functions: serialize and deserialize', () => {
};
const reviver = (_key: string, value: unknown) => {
if (value === 10) return 5;
if (value === undefined) return 'resurrected';
if (value === null) return 'resurrected';
return value;
};
expect(serialize(testObject, replacer)).toEqual(serialize({ a: 10 }));
expect(
deepEqual(testObject, deserialize(serialize(testObject, replacer), reviver)),
).toBeTruthy();
expect(deserialize(serialize({ lazarus: undefined }), reviver).lazarus).toEqual('resurrected');
expect(deserialize(serialize({ lazarus: null }), reviver).lazarus).toEqual('resurrected');
});
it('turn null values in an array into undefined when deserializing', () => {
// Type asserting after deserializing
Expand Down Expand Up @@ -326,16 +334,14 @@ describe('PAPI Util Function: isSerializable', () => {
expect(isSerializable(objectToSerialize)).toBeTruthy();
});

it('UNsuccessfully determines object with `undefined` prop is not serializable', () => {
// TODO: make a deserialization algorithm that does this properly. Not a huge deal for now
it('Successfully determines object with `undefined` prop is serializable', () => {
const objectToSerialize = { stuff: undefined, things: true };
expect(isSerializable(objectToSerialize)).toBeFalsy();
expect(isSerializable(objectToSerialize)).toBeTruthy();
});

it('UNsuccessfully determines object with `null` prop is not serializable', () => {
// TODO: make a deserialization algorithm that does this properly. Not a huge deal for now
it('Successfully determines object with `null` prop is serializable', () => {
const objectToSerialize = { stuff: null, things: true };
expect(isSerializable(objectToSerialize)).toBeFalsy();
expect(isSerializable(objectToSerialize)).toBeTruthy();
});

it('UNsuccessfully thinks object with a Map prop is serializable - it should not be', () => {
Expand Down
76 changes: 41 additions & 35 deletions src/shared/utils/papi-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,21 +161,18 @@ export function deepEqual(a: unknown, b: unknown) {
// #region Serialization, deserialization, encoding, and decoding functions

/**
* Converts a JavaScript value to a JSON string, changing `undefined` properties to `null`
* properties in the JSON string.
* Converts a JavaScript value to a JSON string, changing `undefined` properties in the JavaScript
* object to `null` properties in the JSON string.
*
* WARNING: `null` and `undefined` values are treated as the same thing by this function and will be
* dropped when passed to {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will
* become `{ a: 1 }` after passing through {@link serialize} then {@link deserialize}. If you are
* passing around user data that needs to retain `null` and/or `undefined` values, you should wrap
* them yourself in a string before using this function. Alternatively, you can write your own
* replacer that will preserve `null` and `undefined` values in a way that a custom reviver will
* understand when deserializing.
* WARNING: `null` values will become `undefined` values after passing through {@link serialize} then
* {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will become `{ a: 1, b:
* undefined, c: undefined }`. If you are passing around user data that needs to retain `null`
* values, you should wrap them yourself in a string before using this function. Alternatively, you
* can write your own replacer that will preserve `null` in a way that you can recover later.
*
* @param value A JavaScript value, usually an object or array, to be converted.
* @param replacer A function that transforms the results. Note that all `null` and `undefined`
* values returned by the replacer will be further transformed into a moniker that deserializes
* into `undefined`.
* @param replacer A function that transforms the results. Note that all `undefined` values returned
* by the replacer will be further transformed into `null` in the JSON string.
* @param space Adds indentation, white space, and line break characters to the return-value JSON
* text to make it easier to read. See the `space` parameter of `JSON.stringify` for more
* details.
Expand All @@ -197,39 +194,49 @@ export function serialize(
}

/**
* Converts a JSON string into a value.
* Converts a JSON string into a value, converting all `null` properties from JSON into `undefined`
* in the returned JavaScript value/object.
*
* WARNING: `null` and `undefined` values that were serialized by {@link serialize} will both be made
* into `undefined` values by this function. If those values are properties of objects, those
* properties will simply be dropped. For example, `{ a: 1, b: undefined, c: null }` will become `{
* a: 1 }` after passing through {@link serialize} then {@link deserialize}. If you are passing around
* user data that needs to retain `null` and/or `undefined` values, you should wrap them yourself in
* a string before using this function. Alternatively, you can write your own reviver that will
* preserve `null` and `undefined` values in a way that a custom replacer will encode when
* serializing.
* WARNING: `null` values will become `undefined` values after passing through {@link serialize} then
* {@link deserialize}. For example, `{ a: 1, b: undefined, c: null }` will become `{ a: 1, b:
* undefined, c: undefined }`. If you are passing around user data that needs to retain `null`
* values, you should wrap them yourself in a string before using this function. Alternatively, you
* can write your own replacer that will preserve `null` in a way that you can recover later.
*
* @param text A valid JSON string.
* @param reviver A function that transforms the results. This function is called for each member of
* the object. If a member contains nested objects, the nested objects are transformed before the
* parent object is.
* parent object is. Note that `null` values are converted into `undefined` values after the
* reviver has run.
*/
export function deserialize(
value: string,
reviver?: (this: unknown, key: string, value: unknown) => unknown,
// Need to use `any` instead of `unknown` here to match the signature of JSON.parse
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): any {
const undefinedReviver = (replacerKey: string, replacerValue: unknown) => {
let newValue = replacerValue;
// All `null` values become `undefined` on the way from JSON strings into JS objects
// eslint-disable-next-line no-null/no-null
if (newValue === null) newValue = undefined;
if (reviver) newValue = reviver(replacerKey, newValue);
return newValue;
};
// TODO: Do something like drop our custom reviver and crawl the object tree to replace all null
// properties with undefined properties so that undefined properties don't disappear.
return JSON.parse(value, undefinedReviver);
// Helper function to replace `null` with `undefined` on a per property basis. This can't be done
// with our own reviver because `JSON.parse` removes `undefined` properties from the return value.
function replaceNull(obj: Record<string | number, unknown>): Record<string | number, unknown> {
Object.keys(obj).forEach((key: string | number) => {
// We only want to replace `null`, not other falsy values
// eslint-disable-next-line no-null/no-null
if (obj[key] === null) obj[key] = undefined;
// If the property is an object, recursively call the helper function on it
else if (typeof obj[key] === 'object')
// Since the object came from a string, we know the keys will not be symbols
// eslint-disable-next-line no-type-assertion/no-type-assertion
obj[key] = replaceNull(obj[key] as Record<string | number, unknown>);
});
return obj;
}

const parsedObject = JSON.parse(value, reviver);
// Explicitly convert the value 'null' that isn't stored as a property on an object to 'undefined'
// eslint-disable-next-line no-null/no-null
if (parsedObject === null) return undefined;
if (typeof parsedObject === 'object') return replaceNull(parsedObject);
return parsedObject;
}

/**
Expand All @@ -239,8 +246,7 @@ export function deserialize(
* @returns True if serializable; false otherwise
*
* Note: the values `undefined` and `null` are serializable (on their own or in an array), but
* `undefined` and `null` properties of objects are dropped when serializing/deserializing. That
* means `undefined` and `null` properties on a value passed in will cause it to fail.
* `null` values get transformed into `undefined` when serializing/deserializing.
*
* WARNING: This is inefficient right now as it stringifies, parses, stringifies, and === the value.
* Please only use this if you need to
Expand Down

0 comments on commit a1d26a9

Please sign in to comment.