sdxc

[ Operations & testing ]

Read configuration files

Parse JSON with comments and trailing commas, point a mistake at its line and column, validate the result, and hand it to every command of a command line.

Last updated 2026-10-15

A configuration file is written by people, so it wants comments that say why a setting is set, and it forgives the trailing comma left behind when a line is deleted. That is JSONC, the format of tsconfig.json, editor settings and many tools' own files: JSON plus // and /* */ comments and trailing commas in objects and arrays.

This guide reads a deploy.jsonc for a deploy tool with @sdxc/jsonc, which parses JSONC into exactly what JSON.parse would produce and answers a mistake with its line and column, in a Result.

npm add @sdxc/cli @sdxc/jsonc @sdxc/result remix

The file

deploy.jsonc
{
	// The bucket the site is uploaded to
	"bucket": "assets-production",
	"regions": [
		"eu-west",
		"us-east", // added for the launch
	],
	/* Seconds a CDN may keep a page */
	"maxAge": 300,
}

Everything else follows JSON exactly. Unquoted keys, single-quoted strings and hexadecimal numbers, which JSON5 allows, are mistakes here, so a file that parses is one any JSONC reader agrees on.

Parse it and point at the mistake

parse answers the value, or a JSONCParseError whose message ends with the position:

src/config.ts
import { readFile } from "node:fs/promises";

import { parse } from "@sdxc/jsonc";
import { failure, isFailure, success, wrap } from "@sdxc/result";

export async function readConfigText(path: string) {
	let text = await wrap(() => readFile(path, "utf8"));
	if (isFailure(text)) return failure(new Error(`${path} could not be read`));

	let parsed = parse(text.data);
	if (isFailure(parsed)) {
		let { reason, line, column } = parsed.error;
		return failure(new Error(`${path}:${line}:${column}: ${reason}`));
	}
	return success(parsed.data);
}

Written as path:line:column, the position is one an editor or a terminal opens straight at the character. reason is the message without the position, line and column count from one, and offset is the character's index in the text, for drawing the line with a marker under it:

src/config.ts
import type { JSONCParseError } from "@sdxc/jsonc";

export function frame(text: string, error: JSONCParseError): string {
	let source = text.split(/\r\n|\r|\n/)[error.line - 1] ?? "";
	return `${source}\n${" ".repeat(error.column - 1)}^ ${error.reason}`;
}

The position comes from the parser rather than from the engine's JSON.parse message, so the same file reports the same position and wording on Node, Bun, Deno and Cloudflare Workers. A block comment that never closes is a failure too, at the place it opened.

Validate the shape

A file that parses can still hold the wrong thing: a missing bucket, a string where a number goes. Check the value with a schema once it parses, and report both kinds of mistake with the file's name:

src/config.ts
import * as s from "remix/data-schema";
import { min } from "remix/data-schema/checks";

const DEPLOY_CONFIG = s.object({
	bucket: s.string(),
	regions: s.array(s.string()),
	maxAge: s.defaulted(s.number().pipe(min(0)), 300),
});

export type DeployConfig = s.InferOutput<typeof DEPLOY_CONFIG>;

export async function loadConfig(path: string) {
	let value = await readConfigText(path);
	if (isFailure(value)) return value;

	let checked = s.parseSafe(DEPLOY_CONFIG, value.data);
	if (!checked.success) {
		let messages = checked.issues.map((issue) => issue.message).join("; ");
		return failure(new Error(`${path}: ${messages}`));
	}
	return success(checked.value);
}

defaulted fills maxAge when the file leaves it out, so the rest of the tool reads a number and never checks for absence.

Hand it to every command

A command line reads its configuration once per run, in middleware, and every handler receives it typed:

src/middleware/config.ts
import type { CommandMiddleware } from "@sdxc/cli";

import { join } from "node:path";

import { isFailure } from "@sdxc/result";
import { createContextKey } from "remix/router";

import type { DeployConfig } from "../config.js";

import { loadConfig } from "../config.js";

export const Config = createContextKey<DeployConfig>();

export function config(): CommandMiddleware<{
	key: typeof Config;
	value: DeployConfig;
	property: "config";
}> {
	return async (ctx, next) => {
		let loaded = await loadConfig(join(ctx.cwd, "deploy.jsonc"));
		if (isFailure(loaded)) return loaded;
		ctx.set(Config, loaded.data, { property: "config" });
		await next();
	};
}

A failure returned from the middleware ends the run with its message, before any handler, so deploy.jsonc:10:1: Unexpected character '}' is the whole output of a run against a broken file. Build a command line writes the rest of the tool.

Strip it for another reader

strip turns JSONC into plain JSON of the same length: comments and trailing commas become spaces, and line breaks inside block comments stay. A tool that only reads JSON can then read the file, and any offset it reports is an offset into the original:

src/config.ts
import { strip } from "@sdxc/jsonc";

let json = strip('{ "bucket": "assets", /* staging */ }');
// '{ "bucket": "assets"                }'

parse strips before it parses, so reach for strip only when something other than parse reads the text.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.