Why build another JSON engine? Because JSON.stringify is a tool I use every single day, and for years I had no idea what it actually did. Rebuilding it from scratch turned out to be the perfect project: small enough to finish, deep enough to teach you real things about JavaScript. So today, dear reader, we're writing our own serializer in pure JS. And yes, I LOVE JAVASCRIPT (:
A serializer turns a live value from your language into a JSON-formatted text string — something you can save to a file or send across a network:
{ test: "Bonjour" } ──► '{"test":"Bonjour"}'
a live JS object a string of text
That's the whole job. The interesting part is the edge cases — and JSON has a lot of them.
Everything hangs off one recursive function that switches on typeof:
function stringify(input) {
const seen = new WeakSet() // for circular references — more on this later
function serialize(input) {
switch (typeof input) {
case "string": /* ... */
case "number": /* ... */
case "boolean": /* ... */
case "bigint": /* ... */
case "undefined": /* ... */
case "object": /* ... */
}
}
return serialize(input)
}
We'll fill these in one by one, easiest last-but-one. Strings first, because strings are where beginners get humbled.
Your first instinct is right: wrap the input in quotes and return it. "hello" becomes '"hello"', done. That works... until the string contains a newline, a tab, or a quote. Then your output is broken JSON.
So you escape them: "Hello \n World" must become "Hello \\n World". Smart, GOOD JOB! Now — after handling \n, \t, \r, \" and \\, are we done?
Close. There are about 25 more characters to handle, LOL.
Here's the beautiful part: the character set is organised. Every character in a computer is just a number (that's all "encoding" means — H is 72, or 0x48 in hex), and the designers of ASCII put all the control characters in one block: codes 0 through 31 (0x00–0x1F). So instead of 30 special cases, one range check catches everything:
case "string": {
let str = ""
for (let i = 0; i < input.length; i++) {
const ch = input[i]
if (ch === '"') { str += '\\"' ; continue }
if (ch === "\\") { str += "\\\\" ; continue }
if (ch === "\n") { str += "\\n" ; continue }
if (ch === "\t") { str += "\\t" ; continue }
if (ch === "\r") { str += "\\r" ; continue }
if (input.charCodeAt(i) <= 0x1F) {
// any remaining control char → \u00XX escape
str += "\\u" + input.charCodeAt(i).toString(16).padStart(4, "0")
} else {
str += ch
}
}
return '"' + str + '"'
}
The named escapes (\n, \t...) exist only because they're prettier than \u000a — the range check alone would produce valid JSON. Confession: in vanilla-json I only discovered the range trick at version 0.1.2, so my early hand-written cases stayed in as legacy. They cost basically nothing. красавчик!
Quick remark: strings in JS are primitive values, even though they act object-ish. When you call a method on one, JS wraps it in a temporary object on the fly and throws it away after. You've been using disposable objects this whole time.
Booleans, numbers, BigInts and undefined — this will literally take two minutes:
case "boolean": return String(input)
case "bigint": throw new TypeError("Do not know how to serialize a BigInt")
case "number":
return Number.isFinite(input) ? String(input) : "null"
case "undefined": return undefined
Three of these deserve a sentence:
Numbers: JSON has no way to write Infinity or NaN, so native JSON.stringify quietly turns them into null. We copy that. Think of the serializer as a black hole — whatever falls in comes out stringified on the other side, and what can't be stringified comes out as null.
BigInt: the native one throws here, so we throw the same error. Matching native behavior including the errors is the whole game.
undefined: we return JS undefined itself, not the string "undefined". This is a signal to the caller — you'll see why when we get to objects: a key whose value serializes to undefined simply gets dropped.
And null? Fun fact: typeof null === "object". That's a historical bug, kept forever for compatibility — null is really its own primitive type. It means null falls into our "object" case, so that's where we'll handle it.
case "object": {
if (input === null) return "null" // the typeof-null bug, handled
if (typeof input.toJSON === "function") // duck typing — see below
return serialize(input.toJSON())
if (seen.has(input)) // circular reference guard
throw new TypeError("Converting circular structure to JSON")
seen.add(input) // "I'm inside this one now"
let out
if (Array.isArray(input)) {
const parts = []
for (const item of input)
parts.push(serialize(item) ?? "null") // undefined in arrays → null
out = "[" + parts.join(",") + "]"
} else {
const parts = []
for (const key of Object.keys(input)) {
const piece = serialize(input[key])
if (piece === undefined) continue // undefined values → key dropped
parts.push(serialize(key) + ":" + piece) // serialize the key too — it may need escaping!
}
out = "{" + parts.join(",") + "}"
}
seen.delete(input) // "done, leaving"
return out
}
Four ideas live in this block, and each one is worth having in your head:
Before serializing an object, we check whether it has a toJSON method, and if so we serialize what it returns instead. This is how Date turns into an ISO string, and how you keep a password field from ever being serialized and sent over the internet, lol. The pattern is called duck typing: we don't ask what the object is, only whether it can quack.
Yes — typeof [] is "object", which is why arrays are handled inside this case with Array.isArray. Meanwhile functions get their own typeof... even though under the hood functions are special objects. Is everything in JS an object? No — that's a stereotype. JS is amazing!!
Note the asymmetry we inherit from native: undefined inside an array becomes null (arrays can't have holes in JSON), but undefined as an object value drops the whole key. Same input, two behaviors, both matched.
What happens if an object contains itself?
const a = {}
a.self = a
stringify(a) // without a guard: infinite recursion, stack overflow
The seen WeakSet is the guard. Before descending into an object we check "am I already inside this one?" — if yes, we throw the same TypeError native does. We add on the way in and delete on the way out, so the same object appearing twice (sibling references — fine) is not confused with an object containing itself (circular — fatal). And it's a WeakSet so the guard never keeps objects alive in memory longer than the walk itself.
A subtle one my first version got wrong: object keys go through the string serializer as well, because a key like 'he said "hi"' needs the exact same escaping as any value. Reuse the machinery you already built.
Check the type, escape the strings, match the native quirks, guard the cycles. Recursion does the rest — every nested value just falls back into serialize until only text remains.
Make sure to READ CODE, not just this article — and you, my friend, have built a JSON serializer in pure JavaScript. The parser (tokenizer + recursive descent) is a whole other adventure, and it lives in the same repo.