Skip to content

Choosing an implementation

In most cases the answer is “the one for the language you are writing in” — that is the point of maintaining four. They share a specification, a test corpus and a three-stage pipeline (lexer, parser, resolver), and behaviour is compared across all four before anything is released.

LanguagePackageRequiresForeign formats
TypeScript@o3co/ts.hoconNode.js 22+Subpath exports with optional peer dependencies
Gogithub.com/o3co/go.hoconGo 1.23+A nested module, so the core keeps its own go.mod clean
Rusthocon-parserRust 1.82+Cargo features (`adapters-*`), off by default
Pythonhocon-parserPython 3.11+Submodules with extras (`hocon-parser[yaml]`)

The Requires column is read from each repository’s package.json, go.mod, Cargo.toml and pyproject.toml when this page is built, so it cannot drift from the manifests.

One specification. Every clause of Lightbend’s HOCON.md is a numbered item, and every item has a recorded status per implementation. Behaviour outside the specification — the places where the spec is silent and four independent parsers could reasonably disagree — is pinned separately as a numbered convention, so “undefined” never quietly means “different”.

One corpus. Fixtures and expected values live in xx.hocon, and all four run against it. For the specification items, the expected values are generated from Lightbend’s own implementation, not hand-written.

One pipeline. Lexer, parser, resolver, in that order, with substitution resolution as a distinct phase after the document is fully parsed. A fix in one port is a diff the other three can be read against.

Dependencies. Python is pure standard library. Go and TypeScript carry no runtime dependencies in the core. Rust carries one (indexmap, for insertion-ordered maps). Every foreign-format adapter is opt-in in all four, so nothing reaches the core.

Language-forced divergence. A few differences are not choices. JavaScript strings are UTF-16 and can hold an unpaired surrogate (a lone \uD800 escape), so the TypeScript port accepts one, as does Python (whose strings keep lone surrogates), while Go and Rust refuse it. Go strings are arbitrary bytes where JavaScript strings arrive already decoded. Both are numbered checklist items with the reasoning attached, rather than smoothed over: the surrogate item is out of scope for all four, because no single answer fits every language’s strings, and input decoding is out of scope only where the language has decoded the input before the parser sees it.

Coverage. The four are not at identical points on the checklist at any given moment.

ImplementationSpec-totalIn-scope✅⚠️❌🤷➖
ts.hocon91.9%100.0%19300017
rs.hocon92.9%100.0%19500015
go.hocon92.4%100.0%19400016
py.hocon89.3%97.2%182110017

Two denominators are reported because one alone misleads. Spec-total divides by every item in the specification, including ones an implementation deliberately does not support, and answers “how much of HOCON does this handle?”. In-scope drops that implementation’s out-of-scope items and answers “of what it undertakes, how much is covered?”. Neither is the flattering number by construction.

Full matrix, item by item →

Every implementation can also read JSON with comments, TOML, YAML and Java .properties into the same configuration object, so a single API covers a mixed repository — see format adapters.

If the consumer of your configuration cannot be changed at all, hocon2 converts HOCON to those formats and back from the command line.