DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

JSON to YAML from zero: indentation, quoting, and the Norway problem

Config files love YAML. Kubernetes, GitHub Actions, Docker Compose, half of CI. But the two formats trip people up, mostly because YAML looks simple until it silently changes your data. So for day 30 of SolveFromZero I built a bidirectional JSON to YAML converter, both directions hand-rolled in plain JavaScript with no library, and wrote down how the conversion actually works.

They are the same data, just written differently

The one fact that makes any of this possible: JSON and YAML describe the exact same data model. Objects, arrays, and four scalar types (string, number, boolean, null). Any valid JSON is also valid YAML. What differs is only the notation. JSON marks structure with braces, brackets, quotes and commas. YAML drops almost all of that and uses indentation instead. So converting is never about changing the data. It is about re-printing the same tree in the other style.

That means the JSON side is free. The language already ships JSON.parse and JSON.stringify. All the real work is the YAML side: an emitter that walks a tree and prints indented YAML, and a parser that reads indentation back into a tree.

The emitter: walk the tree, indent by depth

Because YAML structure is indentation, the emitter is a recursion carrying the current depth. At each node it asks one question: scalar or container? A scalar prints inline. A mapping prints key: value for scalar values, or key: followed by its children two spaces deeper. A sequence prints one - item per element. There are no closing brackets, so there is nothing to emit on the way back up. You just stop indenting.

Two small shapes carry nearly everything. A mapping entry is key: value, and that space after the colon is mandatory; key:value with no space is a plain scalar, not a pair. A sequence item is a dash and a space. The one awkward case is an object inside an array. The pretty version hoists the first key onto the dash line so you get the idiomatic form:

- id: 1
  lead: true
- id: 2
  lead: false
Enter fullscreen mode Exit fullscreen mode

Empty containers are the exception. Indentation cannot show emptiness, so they fall back to the inline flow form, {} and [].

The part everyone gets wrong: quoting

This is the heart of a correct emitter. An unquoted YAML scalar is not text, it is interpreted. Bare 123 is a number. Bare true is a boolean. Bare null is null. And the famous one, bare yes, no, on, off are also booleans in YAML 1.1. This is the Norway problem: a list of country codes containing NO silently becomes false.

So a string is only safe to leave bare if it cannot be read as anything else. The emitter quotes a string when it looks like a number, boolean, null, or date, when it starts with a reserved indicator character, or when it contains a ": " or " #" that would break the line. Get this right and the round-trip is lossless: "123" comes back a string, bare 123 comes back a number. The tool has a small live panel where you type a string and see whether it gets quoted and why.

Multi-line strings

A string with newlines would be ugly double-quoted with \n everywhere. YAML's answer is the block scalar. The literal style | keeps the text exactly as written, one indented line at a time, newlines preserved. Its sibling > folds single line breaks into spaces. A trailing chomping indicator controls the final newline: |- strips it, | keeps one, |+ keeps them all. The emitter picks the indicator from how the source string ends so multi-line values survive the trip.

The parser: indentation back into structure

Reading YAML is recursive descent driven by the indent column. Look at the indentation to know the level, and at the first characters to know the kind: a line starting with - is a sequence, a line with a key: is a mapping, anything else is a scalar. Read all siblings at the same indent, descend when a line indents further, return when it dedents. The running indent behaves like a stack, which is how nesting is recovered with no closing brackets. The fiddly bit is finding the key/value colon: it has to be a ": " that is not inside quotes, so the scanner tracks quote state as it scans.

Parsing - id: 1, where a mapping starts right on the dash line, is the classic hard case. The trick I used is to rewrite the dash and its space into plain spaces, turning - id: 1 into id: 1 at a deeper column, then recurse as an ordinary mapping.

What you lose

Two things do not survive a JSON round-trip. Comments, because JSON has no comment syntax and JSON.parse builds a data tree with nowhere to attach them, so port: 80 # prod only comes back as just port: 80. And anchors and aliases (&name / *name), YAML's way of referencing repeated blocks, which JSON cannot express, so they get flattened or dropped. In production reach for js-yaml, which handles the full spec. But rolling a working subset by hand is the fastest way to see that YAML and JSON really are the same data wearing different clothes.

Try it, runs entirely in the browser, no install: https://dev48v.infy.uk/solve/day30-json-to-yaml.html

Top comments (0)