Skip to content

kagal-dev/json-template

Repository files navigation

@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-template

Usage

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 string

Bare vs embedded variables

Bare variables (outside JSON strings) preserve their native type:

compile('{"port": ${port}}').render({ port: 8080 })
// → { port: 8080 }   ← number, not string

Embedded 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 UnresolvedVariableError

API

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-printed

Options:

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.

Licence

MIT

About

JSON template engine with shell-style ${var:-default} variable substitution — type-preserving, compile-once render-many

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors