@bonniernews/json-templates
v0.2.0
Published
Completes incomplete JSON string with caching capabilites
Downloads
1,662
Maintainers
Readme
@bonniernews/json-templates
A template engine for strings, objects, and arrays. Placeholders use {{ }} and support path access, optional chaining, nullish defaults, ternary conditions, logical operators, arithmetic, array methods with arrow functions, template string interpolation, and strict mode.
Installation
npm install @bonniernews/json-templatesAPI
import { render, validate, MissingVariableError, ArithmeticError, ParseError, TypeError, JsonTemplateError } from "@bonniernews/json-templates";
render(template, data, options?)
validate(template, data?)| Argument | Type | Description |
| ---------- | --------------------------- | ----------------------------- |
| template | string \| object \| array | The template to render |
| data | Record<string, any> | The values to substitute |
| options | RenderOptions | Optional settings (see below) |
Options
| Option | Type | Default | Description |
| ----------------- | --------- | ------- | ------------------------------------------------------------------------------ |
| strict | boolean | false | Throw on missing variables and invalid arithmetic |
| preserveMissing | boolean | false | Keep {{expr}} verbatim in output when the expression resolves to undefined |
strict
render("Hello {{name}}", {}, { strict: true });
// → throws MissingVariableError: Missing variable: "name"
render("{{name ?? 'Guest'}}", {}, { strict: true });
// → "Guest" — default covers the missing variable, no throw
render("{{'hello' * 2}}", {}, { strict: true });
// → throws ArithmeticError: Arithmetic operation produced NaN — operands must be numeric
render("{{1 / 0}}", {}, { strict: true });
// → "Infinity" — division by zero is allowedpreserveMissing
When true, any placeholder that resolves to undefined is left unchanged in the output — the original {{expr}} string is preserved verbatim — instead of being replaced with "" or undefined.
Tokens that resolve successfully are rendered as normal. Tokens with a fallback (: default or ??) use their fallback as normal.
render("hello {{name}}", {}, { preserveMissing: true });
// → "hello {{name}}"
render({ city: "{{order.city}}" }, {}, { preserveMissing: true });
// → { city: "{{order.city}}" }
// Mixed: found tokens render, missing tokens are preserved
render("{{first}} {{last}}", { first: "John" }, { preserveMissing: true });
// → "John {{last}}"
// Fallbacks still apply — not treated as missing
render("{{name ?? 'Guest'}}", {}, { preserveMissing: true });
// → "Guest"MissingVariableError extends JsonTemplateError and has a path property with the variable path (e.g. "user.name", "list[0]"). ArithmeticError extends JsonTemplateError and is thrown in strict mode when an arithmetic operation produces NaN (i.e. non-numeric operands). ParseError extends JsonTemplateError and is thrown when a placeholder has invalid syntax. TypeError extends JsonTemplateError and is thrown in strict mode when array methods are called on non-array values. All error classes are exported from the package.
Return type
Return type matches the template type:
stringtemplate → always returnsstring(objects are JSON-stringified)object/arraytemplate → returns the same shape with values rendered; a value that is exactly one{{expr}}returns the raw evaluated value (preserving non-string types)
Example
A JSON template using all preferred syntaxes:
{
"to": "{{user.email}}",
"subject": "{{user.active ? `Welcome back, ${user.firstName}!` : 'Your account is inactive'}}",
"role": "{{user.role || 'viewer'}}",
"isAdmin": "{{user.role && user.active}}",
"optedOut": "{{!user.notifications}}",
"firstTag": "{{user.tags[0]}}",
"contact": "{{user.contact}}",
"contactJson": "Contact info: {{user.contact}}",
"contactPhone": "{{user?.contact?.phone ?? 'not provided'}}",
"Display Name": "{{user['Display Name'] ?? user.firstName ?? 'there'}}",
"Preferred Publication": "{{['Preferred Publication'] ?? 'none'}}"
}With data:
{
"user": {
"email": "[email protected]",
"firstName": "Alice",
"active": true,
"role": "editor",
"notifications": false,
"tags": ["news", "sports"],
"contact": { "phone": "+1 555 0100" },
"Display Name": "Alice Wonderland"
},
"Preferred Publication": "Dagens Nyheter"
}Result:
{
"to": "[email protected]",
"subject": "Welcome back, Alice!",
"role": "editor",
"isAdmin": true,
"optedOut": true,
"firstTag": "news",
"contact": { "phone": "+1 555 0100" },
"contactJson": "Contact info: {\"phone\":\"+1 555 0100\"}",
"contactPhone": "+1 555 0100",
"Display Name": "Alice Wonderland",
"Preferred Publication": "Dagens Nyheter"
}isAdmin, optedOut, and contact pass through as their raw types (boolean / boolean / object) because they are sole placeholders in an object template.
Placeholder syntax
Every placeholder is wrapped in {{ }}.
{{ expression }}Expressions
1. Variable path
{{name}}
{{user.address.city}}2. Array indexing — [n] (preferred)
{{list[0]}}
{{users[0].tags[1]}}
{{matrix[0][1].value}}Legacy dot-index syntax is also supported and can be mixed:
{{users.0.name}}
{{users[0].tags.1}}3. Bracket notation — ['key']
Required for keys containing spaces or other special characters.
{{['Sundsvalls Tidning']}}
{{['Sundsvalls Tidning'].v1}}
{{['Sundsvalls Tidning']['Nested Property']}}
{{items[0]['my key']}}Simple identifiers still use dot notation: {{user.name}}.
4. Optional chaining — ?.
Returns undefined silently if any part of the chain is null or undefined — even in strict mode.
{{user?.name}} ← undefined if user is missing or null
{{user?.address?.city}} ← safe deep access
{{user?.['my key']}} ← optional bracket notation
{{list?.[0]}} ← optional index accessCombine with ?? to provide a fallback:
{{user?.name ?? 'Guest'}}5. Nullish coalescing — expr ?? fallback
Returns the fallback when the value is null, undefined, or missing. false, 0, and "" are not replaced.
Works at any depth — no parentheses needed inside a ternary (unlike : default).
{{name ?? 'Guest'}}
{{user.name ?? 'Anonymous'}}
{{a ? b ?? 'fallback' : 'other'}} ← works directly inside a ternary branch| Value of name | Result |
| --------------- | --------- |
| "Alice" | "Alice" |
| null | "Guest" |
| undefined | "Guest" |
| (missing) | "Guest" |
| "" | "" |
| false | "false" |
| 0 | "0" |
6. Default value — expr:fallback (legacy nullish — top-level only)
Same nullish semantics as ?? but only works at the top level of an expression. To use inside a ternary, wrap in parentheses or use ?? instead.
{{name:'Guest'}}
{{url:'http://example.com'}} ← colon inside quoted default is safe
{{a ? (b:'Guest') : 'other'}} ← parens required inside ternary7. Ternary — condition ? truthy : falsy
false, 0, "", null, and undefined are all falsy.
{{isActive ? 'Active' : 'Inactive'}}
{{user.name ? user.name : 'Guest'}}
{{flag ? a : b}}Ternaries are right-associative — parentheses are optional but accepted for readability:
{{a ? b ? 'both' : 'a only' : 'neither'}}
{{a ? (b ? 'both' : 'a only') : 'neither'}} ← identical result8. Logical operators — &&, ||, !
JavaScript-style short-circuit semantics: && and || return the actual value, not a boolean. ! always returns a boolean.
| Operator | Behaviour |
| ---------- | ----------------------------------------- |
| a && b | Returns a if falsy, otherwise b |
| a \|\| b | Returns a if truthy, otherwise b |
| !a | true if a is falsy, false if truthy |
Precedence (highest → lowest): -x ! > * / % > + - > < > <= >= > == === != !== > && > || > ?? > ternary ? : > default :
{{a && b}}
{{a || b}}
{{!isActive}}
{{a || b ? 'yes' : 'no'}}
{{!isActive ? 'off' : 'on'}}
{{a || b && c}} ← parsed as a || (b && c)9. Comparison — <, >, <=, >=
{{count > 0 ? 'items' : 'empty'}}
{{score >= 90 ? 'A' : 'B'}}
{{a + b > threshold}}10. Equality — ==, ===, !=, !==
Standard JavaScript equality and inequality. ==/!= coerce types; ===/!== do not.
{{status == 'active' ? 'yes' : 'no'}}
{{role === 'admin' ? 'yes' : 'no'}}
{{role !== 'admin' ? 'restricted' : 'allowed'}}
{{a + b === total}}| Operator | Behaviour |
| --------- | ------------------------------------ |
| a == b | Loose equality — coerces types |
| a === b | Strict equality — no type coercion |
| a != b | Loose inequality — coerces types |
| a !== b | Strict inequality — no type coercion |
11. Arithmetic — +, -, *, /, %
Standard arithmetic with JavaScript semantics. + also concatenates strings. - can also be used as unary negation.
{{a + b}}
{{price * quantity}}
{{total - discount}}
{{score / count}}
{{index % 2 === 0 ? 'even' : 'odd'}}
{{-price}}
{{(a + b) * c}}Precedence (highest → lowest within arithmetic): * / % > + -
Operators are left-associative: 8 / 2 / 2 == 2.
In strict mode, an operation that produces NaN (e.g. 'hello' * 2) throws ArithmeticError. Division by zero producing Infinity is allowed in both modes.
12. Array methods with arrow functions
Transform and query arrays using JavaScript-style array methods with arrow function syntax.
Arrow function syntax
x => expression ← single parameter
(a, b) => expression ← multiple parameters
x => ({key: value}) ← object literal shorthand
x => ({[x.id]: x.name}) ← computed/dynamic keysArrow functions support expression bodies only (no {} block syntax). The expression result is the return value.
Supported array methods
| Method | Description | Returns |
| ----------------------- | --------------------------------------- | ---------------------- |
| .map(fn) | Transform each element | New array |
| .filter(fn) | Keep elements matching predicate | New array |
| .find(fn) | Find first element matching predicate | Element or undefined |
| .some(fn) | Check if any element matches predicate | Boolean |
| .reduce(fn, initial?) | Reduce array to single value | Any value |
| .sort(fn?) | Sort array (with optional comparator) | New array |
| .join(separator?) | Join elements into string | String |
| .reverse() | Reverse array order | New array |
| .length | Get array length (property, not method) | Number |
Important: All methods return new arrays and do not mutate the originals.
Examples
// Transform with .map()
render("{{items.map(x => x.name)}}", { items: [{ name: "A" }, { name: "B" }] });
// → '["A","B"]'
// Object literal shorthand
render("{{users.map(u => ({id: u.id, name: u.name}))}}", {
users: [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
});
// → [{id: 1, name: "Alice"}, {id: 2, name: "Bob"}]
// Computed keys (dynamic property names)
render("{{users.map(u => ({[u.id]: u.name}))}}", {
users: [
{ id: "user1", name: "Alice" },
{ id: "user2", name: "Bob" },
],
});
// → [{user1: "Alice"}, {user2: "Bob"}]
// Filter with predicate
render("{{items.filter(x => x.active)}}", {
items: [
{ active: true, name: "A" },
{ active: false, name: "B" },
],
});
// → [{active: true, name: "A"}]
// Filter with comparison
render("{{scores.filter(s => s > 50)}}", { scores: [45, 78, 92, 33] });
// → [78, 92]
// Find first match
render("{{users.find(u => u.id === 2)}}", {
users: [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
});
// → {id: 2, name: "Bob"}
// Check if any match
render("{{items.some(x => x.price > 100)}}", { items: [{ price: 50 }, { price: 150 }] });
// → "true"
// Reduce to sum
render("{{numbers.reduce((sum, x) => sum + x, 0)}}", { numbers: [1, 2, 3, 4] });
// → "10"
// Reduce with index parameter
render("{{items.reduce((acc, item, i) => acc + item * i, 0)}}", { items: [5, 10, 15] });
// → "40"
// Sort with comparator (numeric sort)
render("{{numbers.sort((a, b) => a - b)}}", { numbers: [10, 2, 33, 4] });
// → [2, 4, 10, 33]
// Sort objects by property
render("{{users.sort((a, b) => a.age - b.age)}}", { users: [{ age: 30 }, { age: 20 }, { age: 25 }] });
// → [{age: 20}, {age: 25}, {age: 30}]
// Join with separator
render("{{tags.join(', ')}}", { tags: ["js", "ts", "node"] });
// → "js, ts, node"
// Reverse array
render("{{numbers.reverse()}}", { numbers: [1, 2, 3] });
// → [3, 2, 1]
// Array length property
render("{{items.length}}", { items: [1, 2, 3, 4, 5] });
// → "5"Method chaining
All array methods can be chained together:
render("{{users.filter(u => u.active).map(u => u.name).join(', ')}}", {
users: [
{ active: true, name: "Alice" },
{ active: false, name: "Bob" },
{ active: true, name: "Charlie" },
],
});
// → "Alice, Charlie"
render("{{numbers.filter(n => n > 2).map(n => n * 2).reduce((a, b) => a + b, 0)}}", { numbers: [1, 2, 3, 4, 5, 6] });
// → "36"Error handling
Non-strict mode (default): Array methods gracefully handle non-array values:
| Method | Returns for non-array | Example |
| ------------------- | --------------------- | ------------------------------------------------ |
| .map() | [] | "{{null.map(x => x)}}" → "[]" |
| .filter() | [] | "{{42.filter(x => x)}}" → "[]" |
| .find() | undefined | {v: "{{obj.find(x => x)}}"} → {v: undefined} |
| .some() | false | "{{'text'.some(x => x)}}" → "false" |
| .reduce(fn, init) | init value | "{{null.reduce((a,b) => a+b, 10)}}" → "10" |
| .join() | "" | "{{123.join(',')}}" → "" |
| .reverse() | [] | "{{false.reverse()}}" → "[]" |
| .sort() | [] | "{{'str'.sort()}}" → "[]" |
Strict mode: Throws TypeError with path information:
render("{{value.map(x => x)}}", { value: null }, { strict: true });
// → throws TypeError: Cannot call .map() on non-array value at path: value.map(...)
render("{{user.items.filter(x => x)}}", { user: { items: "not array" } }, { strict: true });
// → throws TypeError: Cannot call .filter() on non-array value at path: user.items.filter(...)Property vs method disambiguation
Objects can have properties named like array methods. Properties are accessed without parentheses; methods require parentheses:
// Property access (no parentheses)
render("{{obj.map}}", { obj: { map: "property value" } });
// → "property value"
// Method call (with parentheses)
render("{{items.map(x => x * 2)}}", { items: [1, 2, 3] });
// → [2, 4, 6]13. Template string interpolation — `text ${expr} text`
Use backtick strings with ${} to embed expressions inline.
{{`Hello ${name}!`}}
{{`${first} ${last}`}}
{{`City: ${user.address.city}`}}
{{`Status: ${isActive ? statusOn : statusOff}`}}Works inside ternary branches:
{{isActive ? `Hello ${name}!` : `Goodbye ${name}`}}
{{a ? `Both: ${name}` : `Neither`}}Object and array templates
The template itself can be an object or array. Each string value is rendered independently.
render({ message: "Hello {{name}}", id: "{{id}}" }, { name: "Alice", id: 42 });
// → { message: "Hello Alice", id: 42 }When a string value in an object or array template is exactly one placeholder and the resolved value is not a string, the raw value is passed through:
render({ data: "{{payload}}" }, { payload: { id: 1 } });
// → { data: { id: 1 } }In a top-level string template, objects are always JSON-stringified:
render("{{user}}", { user: { name: "Alice" } });
// → '{"name":"Alice"}'Validation
validate(template, data?) checks a template against data using the same evaluation logic as render. It returns { isValid: boolean, missing: string[], errors: string[] } and never throws.
validate("Hello {{name}}", { name: "Alice" });
// → { isValid: true, missing: [], errors: [] }
validate("Hello {{name}}", {});
// → { isValid: false, missing: ["name"], errors: [] }
validate({ greeting: "Hi {{name}}", city: "{{location.city}}" }, {});
// → { isValid: false, missing: ["name", "location.city"], errors: [] }
// Syntax errors are reported in errors[] instead of throwing
validate("{{(name}}");
// → { isValid: false, missing: [], errors: ['Syntax error in "{{(name}}": Expected RPAREN but got EOF'] }Because it uses the same logic as render, covered variables are not reported as missing:
validate("{{name ?? 'Guest'}}", {}); // → { isValid: true, missing: [], errors: [] }
validate("{{user?.name}}", {}); // → { isValid: true, missing: [], errors: [] }
validate("{{name : 'Guest'}}", {}); // → { isValid: true, missing: [], errors: [] }Ternary branches are only checked for the branch actually taken with the given data — consistent with how render evaluates them:
validate("{{flag ? name : fallback}}", { flag: true });
// → { isValid: false, missing: ["name"], errors: [] } — fallback is not checked
validate("{{flag ? name : fallback}}", { flag: false });
// → { isValid: false, missing: ["fallback"], errors: [] } — name is not checkedMissing variables
| Context | Default result | With preserveMissing: true |
| ----------------------------------------- | -------------- | ---------------------------- |
| In a string template | "" | "{{expr}}" verbatim |
| As sole placeholder in an object template | undefined | "{{expr}}" verbatim |
| In a ternary / logical condition | falsy | falsy |
| In string interpolation | "" | "{{expr}}" verbatim |
Escape sequences
In the template string
\{{expr}} prevents rendering and emits {{expr}} literally.
"Hello \{{name}}" → "Hello {{name}}"Inside a string literal "..."
| Write | Produces | Notes |
| ----- | -------- | -------------------- |
| \" | " | Literal double-quote |
| \\ | \ | Literal backslash |
Unclosed placeholder
A {{ with no matching }} is left as-is:
"Hello {{name" → "Hello {{name"Complete syntax reference
template = string | object | array — any value containing {{ expr }} blocks
expr = nullish-expr ( ? expr : expr )? — ternary (right-associative)
| nullish-expr : expr — nullish default (top-level only)
nullish-expr= or-expr ( ?? or-expr )*
or-expr = and-expr ( || and-expr )*
and-expr = eq-expr ( && eq-expr )*
eq-expr = cmp-expr ( (==|===|!=|!==) cmp-expr )*
cmp-expr = add-expr ( (<|>|<=|>=) add-expr )*
add-expr = mul-expr ( (+|-) mul-expr )*
mul-expr = unary ( (*|/|%) unary )*
unary = (-|!) unary | primary
primary = path
| arrow-function
| object-literal
| 'string literal' — single quotes preferred (no JSON escaping)
| `template literal`
| number (integer or float) | true | false | null
| ( expr ) — grouping (resets default depth)
path = segment ( accessor | opt-accessor )*
accessor = .segment | [number] | ["string"] | .method-call
opt-accessor= ?.segment | ?.[number] | ?.["string"] | ?.method-call
segment = identifier | number
method-call = identifier ( arg-list )
arg-list = ( expr (, expr)* )?
arrow-fn = identifier => expr — single parameter
| ( identifier (, identifier)* ) => expr — multiple parameters
obj-literal = ( { obj-props } ) — parenthesized for arrow return
obj-props = prop (, prop)*
prop = identifier : expr — static key
| [ expr ] : expr — computed key
template lit= ` chars `
chars = literal text
| ${ expr } — interpolation
| \` — escaped backtick
| \\ — escaped backslashQuick-reference examples
// Variable access
render("Hello {{name}}", { name: "Alice" }); // → "Hello Alice"
render("{{user.address.city}}", { user: { address: { city: "X" } } }); // → "X"
render("{{list[0]}}", { list: ["a", "b"] }); // → "a"
// Keys with spaces
render("{{['My Key']}}", { "My Key": "value" }); // → "value"
// Optional chaining
render({ v: "{{user?.name}}" }, {}); // → { v: undefined }
render("{{user?.name ?? 'Guest'}}", {}); // → "Guest"
// Nullish coalescing
render("{{name ?? 'Guest'}}", {}); // → "Guest"
render("{{name ?? 'Guest'}}", { name: "" }); // → ""
// Legacy default (top-level only)
render("{{name:'Guest'}}", {}); // → "Guest"
// Ternary
render("{{on ? 'yes' : 'no'}}", { on: true }); // → "yes"
render("{{a ? b ?? 'fallback' : 'other'}}", { a: true }); // → "fallback"
// Logical operators
render("{{a && b ? 'both' : 'no'}}", { a: true, b: true }); // → "both"
render("{{name || 'Anonymous'}}", {}); // → "Anonymous"
render("{{!isActive ? 'off' : 'on'}}", { isActive: false }); // → "off"
// Template string interpolation
render("{{`Hello ${name}!`}}", { name: "Alice" }); // → "Hello Alice!"
render("{{isActive ? `Hi ${name}` : `Bye`}}", { isActive: true, name: "Bob" }); // → "Hi Bob"
// Object template — raw value pass-through
render({ data: "{{payload}}" }, { payload: { id: 1 } }); // → { data: { id: 1 } }
// Arithmetic
render("{{a + b}}", { a: 3, b: 4 }); // → "7"
render("{{a + b * c}}", { a: 1, b: 2, c: 3 }); // → "7" (* before +)
render("{{(a + b) * c}}", { a: 1, b: 2, c: 3 }); // → "9"
render("{{1 / 0}}", {}); // → "Infinity"
// Strict mode
render("{{name}}", {}, { strict: true }); // → throws MissingVariableError
render("{{name ?? 'Guest'}}", {}, { strict: true }); // → "Guest"
render("{{'hello' * 2}}", {}, { strict: true }); // → throws ArithmeticError
// Escape
render("Value: \\{{x}}", { x: "ignored" }); // → "Value: {{x}}"
// Validation
validate("{{name}}", { name: "Alice" }); // → { isValid: true, missing: [], errors: [] }
validate("{{name}}", {}); // → { isValid: false, missing: ["name"], errors: [] }
validate("{{name ?? 'Guest'}}", {}); // → { isValid: true, missing: [], errors: [] }
// preserveMissing — keep unresolved tokens verbatim
render("hello {{name}}", {}, { preserveMissing: true }); // → "hello {{name}}"
render({ city: "{{order.city}}" }, {}, { preserveMissing: true }); // → { city: "{{order.city}}" }
render("{{first}} {{last}}", { first: "John" }, { preserveMissing: true }); // → "John {{last}}"
render("{{name ?? 'Guest'}}", {}, { preserveMissing: true }); // → "Guest" (fallback applies)
// Array methods with arrow functions
render("{{items.map(x => x.name)}}", { items: [{ name: "A" }, { name: "B" }] }); // → '["A","B"]'
render("{{numbers.filter(n => n > 5)}}", { numbers: [3, 7, 2, 9] }); // → [7, 9]
render("{{users.find(u => u.id === 2)}}", { users: [{ id: 1 }, { id: 2, name: "Bob" }] }); // → {id: 2, name: "Bob"}
render("{{items.some(x => x > 10)}}", { items: [5, 15, 3] }); // → "true"
render("{{nums.reduce((a, b) => a + b, 0)}}", { nums: [1, 2, 3] }); // → "6"
render("{{numbers.sort((a, b) => a - b)}}", { numbers: [3, 1, 2] }); // → [1, 2, 3]
render("{{tags.join(', ')}}", { tags: ["a", "b", "c"] }); // → "a, b, c"
render("{{items.length}}", { items: [1, 2, 3] }); // → "3"
// Method chaining
render("{{users.filter(u => u.active).map(u => u.name).join(', ')}}", {
users: [
{ active: true, name: "A" },
{ active: false, name: "B" },
],
}); // → "A"
// Object literal with computed keys
render("{{users.map(u => ({[u.id]: u.name}))}}", {
users: [
{ id: "a", name: "Alice" },
{ id: "b", name: "Bob" },
],
}); // → [{a: "Alice"}, {b: "Bob"}]