smol-toml
smol-toml is a TOML parser and serializer for JavaScript with a deliberately small surface: parse(string) gives you a plain object, stringify(object) gives you a TOML document, and that is nearly the whole API. It has no dependencies, ships both ESM and CommonJS builds with bundled types, and targets the TOML 1.1.0 specification. It exists because the previous generation of JavaScript TOML parsers had drifted into being unmaintained, non-compliant, or both, and it now sits underneath a lot of build tooling, which is how a project with a few hundred stars ends up with tens of millions of weekly downloads. Two extra exports round it out: TomlDate, which models the four distinct TOML date and time types that a plain JS Date cannot express, and TomlError, which reports the line, column and a rendered code excerpt for a syntax failure.
The right default for reading and writing TOML in JavaScript today: small, dependency-free, quick, and correct on nearly everything the official test suite throws at it. Decide how you want integers represented before you write any code against it, and do not expect it to preserve comments.
Use it if
- You read TOML config files in Node and want a parser that passes most of the official toml-test suite rather than a hand-rolled regex approximation
- You also write TOML back out; stringify is the fastest serializer in the project's own benchmark, several times ahead of @iarna/toml on both small and large documents
- You want zero dependencies, bundled TypeScript types, and both import and require entry points from a package that is about 11 kB minified
- You need error messages a user can act on: TomlError carries line, column and a formatted code block pointing at the offending character
- You care about TOML date semantics, where an offset date-time, a local date-time, a local date and a local time are four different things that TomlDate keeps distinguishable
- You have integers larger than 53 bits. By default every number comes back as a JavaScript number, so large integers lose precision silently and 1.0 round-trips to 1. Fixing it means integersAsBigInt on the way in and numbersAsFloat on the way out, and then every consumer of your config has to cope with BigInt.
- You need a validator rather than a parser. The project is explicit that it does not reject invalid UTF-8 in strings and comments, and that an impossible date like 2023-02-30 is accepted and quietly becomes 2023-03-02 because the extra checks cost performance.
- You want to edit a config file and keep it readable. parse followed by stringify discards every comment and all original formatting, so it cannot do an in-place edit of a human-maintained file. @decimalturn/toml-patch exists for exactly that job.
- Your consumers only implement TOML 1.0.0. This targets 1.1.0, so a document it happily writes using newer syntax may fail to parse in another language's parser.
- You need CommonJS-first or old Node. The package is type: module with a CJS fallback and declares node >= 18.
- You want a large maintainer bench. This is a single-maintainer project with 298 stars and 8 open issues (15 issues and PRs). The download figure reflects build tools depending on it, not thousands of independent evaluations.
Setup reality
npm install smol-toml and there is nothing else to configure: no dependencies, no build step, types included, and both an ESM and a CJS entry so it works either way on Node 18 or newer. The decisions that actually cost you time come after the install. First, integer handling has to be settled at the boundary, because switching integersAsBigInt on later changes the runtime type of values all through your code, and 'asNeeded' gives you a union of number and bigint that TypeScript will make you narrow at every use. Second, the stringify contract is asymmetric in a way that will surprise you at runtime: undefined and null on an object property are silently dropped and produce no key, but the same values inside an array throw, and functions, classes and symbols throw as well. So the same config object serializes fine or blows up depending on where the empty value happens to sit. Third, parse defaults to maxDepth 1000 and throws a TomlError once nesting exceeds it, which is the knob you want to turn down before feeding it anything user-supplied.
Patterns
Parse a TOML stringparse-toml
import { parse } from 'smol-toml'
const config = parse(`
title = "My App"
[server]
port = 8080
hosts = ["a.example.com", "b.example.com"]
`)
console.log(config.title, config.server.port)Tables come back as plain objects and arrays as plain arrays, with no prototype tricks and no wrapper classes, so the result is safe to hand to JSON.stringify or a schema validator.
Load a config file from diskread-config-file
import { readFile } from 'node:fs/promises'
import { parse } from 'smol-toml'
const raw = await readFile('config.toml', 'utf8')
const config = parse(raw)Read it as utf8 and pass a string; the parser does not accept a Buffer. A file with a UTF-8 BOM will fail to parse, so strip it if your config might come from a Windows editor.
Serialize an object back to TOMLstringify-toml
import { stringify } from 'smol-toml'
const toml = stringify({
title: 'My App',
server: { port: 8080, hosts: ['a.example.com'] },
})Comments and the original key ordering of a parsed document are not preserved, so this is for generating files, not for editing one a human maintains.
Use it like the JSON globaljson-style-namespace
import * as TOML from 'smol-toml'
const obj = TOML.parse(text)
const out = TOML.stringify(obj)
// CommonJS
const TOML = require('smol-toml')There is also a default export carrying the same four members, so both import TOML from and import * as TOML work. Pick one style per codebase to avoid confusing bundler interop.
Keep integers exact past 53 bitsbigint-integers
import { parse } from 'smol-toml'
parse('id = 9007199254740993', { integersAsBigInt: true })
// { id: 9007199254740993n } every integer is a bigint
parse('a = 1\nb = 9007199254740993', { integersAsBigInt: 'asNeeded' })
// { a: 1, b: 9007199254740993n } bigint only when required"asNeeded" keeps small values as numbers, which is friendlier at runtime but gives TypeScript a number | bigint union you have to narrow everywhere. Also remember JSON.stringify throws on BigInt, so this choice leaks into anything that serializes the config.
Stop floats collapsing into integerspreserve-float-types
import { parse, stringify } from 'smol-toml'
stringify(parse('a = 1.0')) // 'a = 1'
stringify({ a: 1.0 }, { numbersAsFloat: true }) // 'a = 1.0'With numbersAsFloat every plain number serializes as a float and only BigInt values serialize as integers, which is the only way to get end-to-end type preservation when combined with integersAsBigInt on parse.
Report a syntax error usefullyhandle-parse-errors
import { parse, TomlError } from 'smol-toml'
try {
parse(raw)
} catch (err) {
if (err instanceof TomlError) {
console.error(`config.toml:${err.line}:${err.column}`)
console.error(err.codeblock)
}
throw err
}codeblock is a pre-rendered excerpt with the surrounding lines and a caret under the offending character. err.message already embeds it, so print one or the other, not both.
Tell the four TOML date types aparttoml-date-types
import { parse, TomlDate } from 'smol-toml'
const { a, b, c, d } = parse(`
a = 1979-05-27T07:32:00-08:00
b = 1979-05-27T07:32:00
c = 1979-05-27
d = 07:32:00
`)
console.log(a.isDateTime(), a.isLocal()) // true, false (offset date-time)
console.log(b.isLocal()) // true (local date-time)
console.log(c.isDate(), d.isTime()) // true, trueA plain JS Date cannot express "a date with no time zone", so smol-toml returns TomlDate, a Date subclass with the type flags attached. toISOString() on it prints only the parts the original value actually had.
Serialize a JS Date as a specific TOML date typewrite-date-values
import { stringify, TomlDate } from 'smol-toml'
const now = new Date()
stringify({ at: now }) // offset date-time
stringify({ on: TomlDate.wrapAsLocalDate(now) }) // 1979-05-27
stringify({ at: TomlDate.wrapAsLocalTime(now) }) // 07:32:00
stringify({ at: TomlDate.wrapAsLocalDateTime(now) })A bare Date always serializes as an offset date-time. If you want a local date or time in the output you have to wrap it explicitly, because the information is not recoverable from the Date itself.
Bound the parser on untrusted inputlimit-nesting-depth
import { parse, TomlError } from 'smol-toml'
try {
parse(userSubmitted, { maxDepth: 20 })
} catch (err) {
if (err instanceof TomlError) return reject('config too deeply nested')
throw err
}The default is 1000, which is generous for a config file and plenty of room for a hostile document to burn CPU. Exceeding the limit throws a TomlError rather than overflowing the stack. maxDepth is not in the README; it is only visible in the source.
Type the parsed value in TypeScripttype-the-result
import { parse, type TomlTable, type TomlTableWithoutBigInt } from 'smol-toml'
const plain: TomlTableWithoutBigInt = parse(raw)
const withBig: TomlTable = parse(raw, { integersAsBigInt: true })
interface Config { server: { port: number } }
const config = parse(raw) as unknown as ConfigThe overloads pick the return type from the options object, so the bigint union only appears when you asked for it. Neither type validates your schema; run the result through zod or valibot if the shape matters.
Know what stringify refuses to writeserialization-pitfalls
stringify({ a: 1, b: undefined, c: null }) // 'a = 1' b and c vanish
stringify({ list: [1, null] }) // throws
stringify({ fn: () => 1 }) // throws
stringify({ id: Symbol('x') }) // throwsTOML has no null, so an empty object property is dropped without warning while the same value in an array is an error. Strip undefined and null before serializing if you want the behaviour to be predictable.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @ltd/j-toml | npm | You need strict spec conformance with explicit control over which TOML version you accept |
| @iarna/toml | npm | An older project already depends on it and you only need it to keep parsing |
| js-toml | npm | You want a TypeScript-first parser built on a formal grammar rather than a hand-written scanner |