Skip to content

Commit

Permalink
fix(tinytype): toJSON supports serialising plain objects with nested …
Browse files Browse the repository at this point in the history
…Maps and Sets
  • Loading branch information
jan-molak committed Jun 5, 2022
1 parent f6212a1 commit bcd4226
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 52 deletions.
47 changes: 47 additions & 0 deletions spec/TinyType.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,53 @@ describe('TinyType', () => {
expect(parameters.toJSON()).to.deep.equal(['apples', 'bananas', 'cucumbers']);
});

it('should serialise a plain-old JavaScript object with nested complex types recursively', () => {
interface NotesType {
authCredentials: {
username: string;
password: string;
},
names: Set<FirstName>;
age: Map<FirstName, Age>;
}

class Notes extends TinyTypeOf<NotesType>() {
}

const
alice = new FirstName('Alice'),
bob = new FirstName('Bob'),
cindy = new FirstName('Cindy');

const names = new Set<FirstName>([ alice, bob, cindy ]);
const age = new Map<FirstName, Age>()
.set(alice, new Age(23))
.set(bob, new Age(42))
.set(cindy, new Age(67));

const notes = new Notes({
authCredentials: {
username: 'Alice',
password: 'P@ssw0rd!',
},
names,
age
});

expect(notes.toJSON()).to.deep.equal({
authCredentials: {
username: 'Alice',
password: 'P@ssw0rd!',
},
names: [ 'Alice', 'Bob', 'Cindy' ],
age: {
Alice: 23,
Bob: 42,
Cindy: 67,
}
});
});

it(`should JSON.stringify any object that can't be represented in a more sensible way`, () => {
class TT extends TinyTypeOf<number>() {
}
Expand Down
25 changes: 17 additions & 8 deletions src/TinyType.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ensure } from './ensure';
import { equal, significantFieldsOf, stringify } from './objects';
import { equal, isRecord, significantFieldsOf, stringify } from './objects';
import { isDefined } from './predicates';
import { JSONObject, JSONValue, NonNullJSONPrimitive, Serialisable } from './types';

Expand Down Expand Up @@ -145,29 +145,38 @@ function toJSON(value: any): JSONObject | NonNullJSONPrimitive {
case value && Array.isArray(value):
return value.map(v => toJSON(v));
case value && value instanceof Map:
return toJSON(Object.fromEntries(value));
return mapToJSON(value);
case value && value instanceof Set:
return toJSON(Array.from(value));
case value && isObject(value):
return JSON.parse(JSON.stringify(value));
case value && isRecord(value):
return recordToJSON(value);
case isSerialisablePrimitive(value):
return value;
default:
return JSON.stringify(value);
}
}

function mapToJSON(map: Map<any, any>): JSONObject {
const serialised = Array.from(map, ([key, value]) => [ toJSON(key), toJSON(value) ]);

return Object.fromEntries(serialised);
}

function recordToJSON(value: Record<any, any>): JSONObject {
const serialised = Object.entries(value)
.map(([ k, v ]) => [ toJSON(k), toJSON(v) ]);

return Object.fromEntries(serialised);
}

function isSerialisableNumber(value: unknown): value is number {
return typeof value === 'number'
&& ! Number.isNaN(value)
&& value !== Number.NEGATIVE_INFINITY
&& value !== Number.POSITIVE_INFINITY;
}

function isObject(value: unknown): value is object {
return Object(value) === value;
}

function isSerialisablePrimitive(value: unknown): value is string | boolean | number | null | undefined {
return ['string', 'boolean', 'null', 'undefined'].includes(typeof value)
|| isSerialisableNumber(value);
Expand Down
2 changes: 2 additions & 0 deletions src/objects/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './deprecated';
export * from './equal';
export * from './isObject';
export * from './isRecord';
export * from './significantFields';
export * from './stringify';
10 changes: 10 additions & 0 deletions src/objects/isObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @access private
*/
export function isObject(value: unknown): value is object {
return value !== null
&& value !== undefined
&& typeof value === 'object'
&& Array.isArray(value) === false
&& Object.prototype.toString.call(value) === '[object Object]';
}
25 changes: 25 additions & 0 deletions src/objects/isRecord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { isObject } from './isObject';

export function isRecord(value: unknown): value is Record<any, any> {
if (! isObject(value)) {
return false;
}

// If has modified constructor
if (value.constructor === undefined) {
return true;
}

// If has modified prototype
if (! isObject(value.constructor.prototype)) {
return false;
}

// If constructor does not have an Object-specific method
if (! Object.prototype.hasOwnProperty.call(value.constructor.prototype, 'isPrototypeOf')) {
return false;
}

// Most likely a plain Object
return true;
}
46 changes: 2 additions & 44 deletions src/predicates/isPlainObject.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,6 @@
import { isRecord } from '../objects';
import { Predicate } from './Predicate';

/**
* @package
*/
const toString = Object.prototype.toString;

/**
* @package
*/
const hasOwnProperty = Object.prototype.hasOwnProperty;

/**
* @package
*/
function isObject(value) {
return value !== null
&& value !== undefined
&& typeof value === 'object'
&& Array.isArray(value) === false
&& toString.call(value) === '[object Object]';
}

/**
* @desc
* Ensures that the `value` is a plain {@link Object}.
Expand All @@ -36,27 +16,5 @@ function isObject(value) {
* @returns {Predicate<string>}
*/
export function isPlainObject<T extends object = object>(): Predicate<T> {
return Predicate.to(`be a plain object`, (value: T) => {
if (! isObject(value)) {
return false;
}

// If has modified constructor
if (value.constructor === undefined) {
return true;
}

// If has modified prototype
if (! isObject(value.constructor.prototype)) {
return false;
}

// If constructor does not have an Object-specific method
if (! hasOwnProperty.call(value.constructor.prototype, 'isPrototypeOf')) {
return false;
}

// Most likely a plain Object
return true;
});
return Predicate.to(`be a plain object`, (value: T) => isRecord(value));
}

0 comments on commit bcd4226

Please sign in to comment.