[ 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
{
// 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:
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:
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:
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:
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:
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
Build a command line — the commands this configuration is handed to.
Resulteverywhere — the shapeparseandloadConfiganswer.Standard Schema validation — the schemas that check the parsed value.
@sdxc/jsonc— every field of the parse error.