My implementation of the JSON.stringify method, calling the function recursively from inside the iteratee of map.
const { expect } = require('chai');
const stringify = require('../src/stringify');
describe('stringify', () => {
it('stringifies primitive types', () => {
expect(stringify(42)).to.equal('42');
expect(stringify(true)).to.equal('true');
expect(stringify('hello')).to.equal('"hello"');
expect(stringify(undefined)).to.equal('undefined');
expect(stringify(null)).to.equal('null');
});
it('stringifies an empty array', () => {
expect(stringify([])).to.equal('[]');
});
it('stringifies a flat array containing primitive values', () => {
var input = [42, true, 'hello', null, undefined];
var expected = JSON.stringify(input);
expect(stringify(input)).to.equal(expected);
});
it('stringifies a nested array containing arrays and primitive values', () => {
var input = [42, [true, ['hello', null]], undefined];
var expected = JSON.stringify(input);
expect(stringify(input)).to.equal(expected);
});
it('stringifies an empty object', () => {
expect(stringify({})).to.equal('{}');
});
it('stringifies a flat object containing primitive values', () => {
var input = {name: 'Mauro', age: 28, isSleepy: true};
var expected = JSON.stringify(input);
expect(stringify(input)).to.equal(expected);
});
});function stringify (value) {
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
value === undefined ||
value === null
) return String(value);
if (typeof value === 'string') return `"${value}"`;
if (Array.isArray(value)) {
const commaJoinedValues = value.map((val) => {
return val === undefined ? 'null' : stringify(val);
}).join(',');
return `[${commaJoinedValues}]`
}
if (typeof value === 'object') {
const commaJoinedValues = Object.keys(value).map((key) => {
return stringify(key) + ':' + stringify(value[key]);
}).join(',');
return `{${commaJoinedValues}}`;
}
}
module.exports = stringify;Recently we gave our students the task of implementing the JSON.stringify method as an exercise to practice recursion. Up until that poing people had been writing recursive steps that either sliced strings and arrays or reduced numbers to smaller ones. But this exercise was the first one to break that pattern. To solve it, I used a pattern that I haven't seen too frequently: calling a recursive function from inside map. Let me share with you my thought and how I reached my implementation.
Stringifying single values is easy, you just pass them to String() and you're done. The tricky part is to stringify any array or object that could contain any number of nested arrays or objects (that in turn could contain even more!). That's the recursive step of the function. So it's clear that our base case, the condition that needs to be met to stop calling the function, is when the passed argument is a primitive value. All we have to do is check types, call String(value) and make sure to explicitly wrap strings in double quotation marks.
Here are my tests:
// stringify.spec.js
it('stringifies primitive types', () => {
expect(stringify(42)).to.equal('42');
expect(stringify(true)).to.equal('true');
expect(stringify('hello')).to.equal('"hello"');
expect(stringify(undefined)).to.equal('undefined');
expect(stringify(null)).to.equal('null');
});
And here's my implementation:
// stringify.js
function stringify (value) {
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
value === undefined ||
value === null
) return String(value);
if (typeof value === 'string') return `"${value}"`;
}
The only two types left are arrays and objects. Arrays are simpler, so let's start with them. Like a good test-driven developer, I started with simple cases: an empty array and a flat array containing primitives.
Here are the tests I added:
// stringify.spec.js
it('stringifies an empty array', () => {
expect(stringify([])).to.equal('[]');
});
it('stringifies a flat array containing primitive values', () => {
var input = [42, true, 'hello', null, undefined];
var expected = '[42, true, "hello", null, undefined]';
expect(stringify(input)).to.equal(expected);
});
Now here's where the magic happens. My thought process was: "So I have an array of values to stringify and a function that already does that for me: stringify itself. The fact that it's the same function that I'm in is besides the point. What's stopping me from mapping the array with this function? Nothing! The only extra things I needed to sort out were:
.join(',') and wrapping everything in some stringified square brackets.undefined value in the middle of an array (which JSON.stringify turns into null)Here's my implementation:
function stringify (value) {
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
value === undefined ||
value === null
) return String(value);
if (typeof value === 'string') return `"${value}"`;
// +++
if (Array.isArray(value)) {
const commaJoinedValues = value.map((val) => {
return val === undefined ? 'null' : stringify(val);
}).join(',');
return `[${commaJoinedValues}]`
}
}
What I like most about this implementation is how readable it is and how, as soon as you finish typing it, you can tell that it will clearly work for any arbitrarily nested array of arrays and primitive values. It has no choice but to! If it the call to stringify inside map gets passed a primitive value we're in the base case and if it gets passed another array, then the whole process we're already in starts again. We should add a test for nested arrays, but it will pass immediately.
At this point I got bored of typing the expected output so I just called the original JSON.stringify to avoid typos.
// stringify.spec.js
it('stringifies a nested array containing arrays and primitive values', () => {
var input = [42, [true, ['hello', null]], undefined];
var expected = JSON.stringify(input);
expect(stringify(input)).to.equal(expected);
});
The last step is to make it work for objects too. The approach for testing and implementing the functionality is exactly the same as for arrays:
// stringify.spec.js
it('stringifies an empty object', () => {
expect(stringify({})).to.equal('{}');
});
it('stringifies a flat object containing primitive values', () => {
var input = {name: 'Mauro', age: 28, isSleepy: true};
var expected = JSON.stringify(input);
expect(stringify(input)).to.equal(expected);
});
The quickest way to map an object's key-value pairs is to map the array of its keys. Keys are always strings, so they're not a problem. Values are also not a problem because we already handled all posible cases. Whatever the value is, stringify has our back and will recursively build up a string, no matter how nested it is.
function stringify (value) {
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
value === undefined ||
value === null
) return String(value);
if (typeof value === 'string') return `"${value}"`;
if (Array.isArray(value)) {
const commaJoinedValues = value.map((val) => {
return val === undefined ? 'null' : stringify(val);
}).join(',');
return `[${commaJoinedValues}]`
}
// +++
if (typeof value === 'object') {
const commaJoinedValues = Object.keys(value).map((key) => {
return stringify(key) + ':' + stringify(value[key]);
}).join(',');
return `{${commaJoinedValues}}`;
}
}
And that's it. There's nothing too complicated going on here, just mapping an array with a function that just happens to be recursive, but it's the sort of thing that you don't think about until you either see someone else do or you have flash of inspiration. What I tend to do when I'm writing a recursive function and I need to call it is to treat it like any other function that returns a value based on the input you give it (which it is, it's just hard to believe because you are calling it before you are done implementing it).
I'm always happy to make my code simpler and more composable by using array methods like map. Hope this little trick was useful :)