@kagal/json-template
v0.1.3
Published
JSON template engine with shell-style ${var:-default} variable substitution
Maintainers
Readme
@kagal/json-template
A TypeScript template engine for JSON documents with
shell-style ${var:-default} variable substitution.
Compiles once, renders to native JavaScript objects —
types are preserved, not stringified.
Why
JavaScript template literals handle string interpolation
well but don't understand JSON structure — a number like
${port} can silently become the string "8080",
special characters in strings break JSON syntax, and
there's no way to list which variables a template
expects.
This engine treats JSON structure as a first-class
concern. It parses the template at compile time,
understands whether each variable sits in a bare value
position or inside a string, and assembles a native JS
object at render time — no string concatenation of JSON,
no JSON.parse at render time.
Installation
npm install @kagal/json-templateUsage
import { compile } from '@kagal/json-template';
const tpl = compile(
'{"host": "${host:-localhost}", "port": ${port:-3000}}'
);
tpl.render({});
// → { host: "localhost", port: 3000 }
tpl.render({ host: "10.0.0.1", port: 8080 });
// → { host: "10.0.0.1", port: 8080 }
tpl.toJSON({}, 2);
// → pretty-printed JSON stringBare vs embedded variables
Bare variables (outside JSON strings) preserve their native type:
compile('{"port": ${port}}').render({ port: 8080 })
// → { port: 8080 } ← number, not stringEmbedded variables (inside "...") concatenate as
strings:
compile('{"addr": "${host}:${port}"}')
.render({ host: "localhost", port: 3000 })
// → { addr: "localhost:3000" }The position is determined at compile time by tracking JSON string context, not with a runtime heuristic.
Shell-style defaults
Variables can specify a fallback value using the :-
separator, matching POSIX shell parameter expansion:
compile(
'{"host": "${host:-localhost}", "port": ${port:-3000}}'
).render({})
// → { host: "localhost", port: 3000 }For bare variables, defaults are JSON-parsed to preserve
type: ${port:-3000} defaults to the number 3000,
${flag:-true} to the boolean true. If the default
isn't valid JSON, it falls back to a plain string.
For embedded variables, defaults are always treated as
strings (they're inside a "..." already).
Defaults with nested JSON
Default values can contain nested JSON with balanced braces:
compile('{"cfg": ${cfg:-{"retries":3}}}').render({})
// → { cfg: { retries: 3 } }Dotted key paths
Variable names can use dotted notation to traverse
nested context objects. Resolution only follows own
properties, so inherited keys like toString,
constructor, and __proto__ are treated as missing:
compile('{"h": "${server.host}"}')
.render({ server: { host: "10.0.0.1" } })
// → { h: "10.0.0.1" }Static analysis
Extract variable metadata without compiling (does not require valid JSON):
import { listVariables } from '@kagal/json-template';
listVariables('{"a": "${name}", "b": ${port:-3000}}')
// → [
// { name: "name", bare: false, ... },
// { name: "port", bare: true, defaultValue: "3000", ... }
// ]Strict mode
compile('{"v": ${required}}', { strict: true }).render({})
// throws UnresolvedVariableErrorAPI
compile(template, options?)
Parses and compiles a JSON template string. Returns a
Template instance.
const tpl = compile(
'{"port": ${port:-3000}, "host": "${host:-localhost}"}'
);
tpl.variables // readonly TemplateVariable[]
tpl.names // ReadonlySet<string>
tpl.render({}) // → { port: 3000, ... }
tpl.render({ port: 8080 }) // → { port: 8080, ... }
tpl.toJSON({}) // → JSON string
tpl.toJSON({}, 2) // → pretty-printedOptions:
| Option | Default | Description |
|----------|---------|---------------------------------|
| strict | false | Throw UnresolvedVariableError when a variable has no value and no default. When false, bare unresolved variables become null and embedded ones become "". |
listVariables(template)
Static analysis only — extracts variable metadata without requiring valid JSON. Useful for tooling, documentation generation, or validation.
listVariables('{"a": "${name}", "b": ${port:-3000}}')
// → [
// { name: "name", bare: false, ... },
// { name: "port", bare: true, ... }
// ]TemplateVariable
Each variable occurrence exposes:
| Field | Type | Description |
|----------------|-----------|------------------------|
| raw | string | Full expression text |
| name | string | Variable name |
| defaultValue | string? | Raw default after :- |
| bare | boolean | Bare vs embedded |
| offset | number | $ offset in source |
Variable name rules
Names are dot-separated segments where each segment
matches /^[a-zA-Z_][a-zA-Z0-9_-]*$/ — letters,
digits, underscores, and hyphens, starting with a
letter or underscore. Dots delimit path segments for
nested context traversal.
Errors
| Error | When |
|--------------------------|----------------------------|
| TemplateParseError | Unterminated ${, empty expression, invalid name, variable in key, reserved sentinel character, or invalid JSON after extraction |
| UnresolvedVariableError| strict: true and no value or default |
jsonNull
The null value used by bare unresolved variables
(when strict is false). Use it when you need to
explicitly pass or check for JSON null:
import { compile, jsonNull } from '@kagal/json-template';
compile('{"v": ${missing}}').render({})
// → { v: null } (jsonNull)isNull(value)
Type guard — returns true when value is null.
Unresolved variable behaviour
| Position | strict: true | strict: false |
|--------------------|-----------------|-----------------|
| Bare (no default) | throws | null |
| Embedded (no def.) | throws | "" |
| Any (with default) | uses default | uses default |
Embedded non-primitive coercion
When an object or array is resolved in an embedded
(string) position, it is serialised via
JSON.stringify rather than String():
compile('{"msg": "config=${cfg}"}')
.render({ cfg: { retries: 3 } })
// → { msg: 'config={"retries":3}' }
// not 'config=[object Object]'Primitives (number, boolean, null) use
String() as expected.
Provenance
Published with npm provenance via GitHub Actions OIDC — no long-lived tokens involved.
