@hammadryaz/braces
v2.0.2
Published
A tiny, fast, dependency-free template engine for universal {curly-brace} interpolation. TypeScript-first, XSS-safe, filter pipes, streaming SSR.
Downloads
104
Maintainers
Readme
braces
A tiny, fast, dependency-free template engine for universal {curly-brace} interpolation.
It's a production-grade, ultra-lightweight (< 1KB min+gzipped), zero-dependency curly-brace template engine designed for high-performance applications, serverless environments, and edge networks.
npm install @hammadryaz/bracesWhy braces?
JavaScript template literals are great — until your strings come from a database, API response, CMS, or config file. At that point, interpolation stops working and you write str.replace() loops forever.
braces is the missing primitive: a first-class, safe, fast interpolation engine for any string, from anywhere.
import { render } from "@hammadryaz/braces";
// ✅ Works on any string — not just code you write
const template = fetchFromDatabase(); // "Welcome back, {user.name}!"
render(template, { user: { name: "Alice" } });
// → "Welcome back, Alice!"Feature matrix
| | braces | Mustache | Handlebars | lodash.template | EJS | | ----------------------- | :--------: | :------: | :--------: | :-------------: | :--: | | Bundle (min+gz) | ~1.1kB | ~9kB | ~22kB | ~17kB | ~8kB | | Zero dependencies | ✅ | ✅ | ❌ | ✅ | ❌ | | TypeScript-native | ✅ | ❌ | ❌ | ❌ | ❌ | | Inferred data types | ✅ | ❌ | ❌ | ❌ | ❌ | | Nested paths | ✅ | ✅ | ✅ | ❌ | — | | Array access | ✅ | ✅ | ✅ | ❌ | — | | Default values | ✅ | ❌ | ❌ | ❌ | ❌ | | Filter pipes | ✅ | ❌ | ✅ | ❌ | ❌ | | Custom delimiters | ✅ | ✅ | ❌ | ✅ | ✅ | | Escape sequences | ✅ | ❌ | ❌ | ❌ | ❌ | | HTML escaping | ✅ | ✅ | ✅ | ❌ | ✅ | | Streaming SSR | ✅ | ❌ | ❌ | ❌ | ❌ | | Isolated renderer | ✅ | ❌ | ❌ | ❌ | ❌ | | LRU cache | ✅ O(1) | ❌ | ✅ | ❌ | ❌ | | Prototype safety | ✅ | ✅ | ✅ | ❌ | ❌ | | Tree-shakeable | ✅ | ❌ | ❌ | ❌ | ❌ |
Quick start
import { render } from "@hammadryaz/braces";
render("Hello {name}!", { name: "World" });
// → "Hello World!"Core API
render(template, data?, options?)
Render a template string with provided data. Templates are compiled and cached in an O(1) LRU cache automatically — repeated calls with the same template string hit zero parsing overhead.
render(template: string, data?: DataObject, options?: RenderCallOptions): stringTypeScript inference — when template is a string literal, TypeScript statically
extracts placeholder names and enforces them in data:
render("Hello {name}", { name: "Alice" }); // ✅ — correct
render("Hello {name}", { nmae: "Alice" }); // ❌ — typo caught at compile time
render("Hello {name}", { name: "Alice", extra: 1 }); // ✅ — extra keys allowedprecompile(template)
Pre-compile a template into a reusable function. Use this on hot paths. Parsing happens once; every subsequent call only resolves values.
const greet = precompile("Hello {firstName} {lastName}!");
greet({ firstName: "Alice", lastName: "Smith" }); // "Hello Alice Smith!"
greet({ firstName: "Bob", lastName: "Jones" }); // "Hello Bob Jones!"createRenderer(options?)
Create an isolated renderer with its own LRU cache, baked-in defaults, and filter registry. This is the recommended API for production applications.
import { createRenderer } from "@hammadryaz/braces";
const renderer = createRenderer({
escapeHtml: true,
missing: "warn",
filters: {
currency: (v) => "$" + Number(v).toFixed(2),
date: (v) => new Date(v).toLocaleDateString(),
},
});
renderer.render("Price: {price|currency}", { price: "19.99" });
// → "Price: $19.99"renderToStream(template, data?, options?)
Render as an AsyncIterable<string>. Each text segment is yielded immediately, without waiting for the full template to resolve. Designed for SSR to improve Time To First Byte.
import { renderToStream } from "@hammadryaz/braces";
for await (const chunk of renderToStream(
"<h1>{title}</h1><p>{body}</p>",
data,
)) {
res.write(chunk);
}Full Feature Reference
Basic interpolation
render("Hello {name}", { name: "John" });
// → "Hello John"
render("My name is {name} and I am {age} years old", { name: "John", age: 20 });
// → "My name is John and I am 20 years old"Nested objects (dot notation)
render("Hello {user.profile.displayName}", {
user: { profile: { displayName: "Alice" } },
});
// → "Hello Alice"Array access (bracket notation)
render("First: {items[0]}", { items: ["Alpha", "Beta"] });
// → "First: Alpha"
render("Winner: {leaderboard[0].name}", {
leaderboard: [{ name: "Alice", score: 9800 }],
});
// → "Winner: Alice"Default values
Use : inside a placeholder to define a fallback value. Colons in the fallback itself are preserved — URL defaults work correctly:
render("Hello {name:Guest}", {}); // → "Hello Guest"
render("Visit {url:https://example.com}", {}); // → "Visit https://example.com"
render("Hello {name:Guest}", { name: "Alice" }); // → "Hello Alice"
render("{n:99}", { n: 0 }); // → "0" (0 ≠ missing)Filter pipes
Apply transformations to resolved values with |:
render("{name|upper}", { name: "alice" }); // → "ALICE"
render("{name|trim|capitalize}", { name: " alice " }); // → "Alice"
render("{title|slug}", { title: "Hello World!" }); // → "hello-world"
render("{q|urlencode}", { q: "hello world & more" }); // → "hello%20world%20%26%20more"Built-in filters: upper, lower, trim, capitalize, slug, urlencode, urldecode, json, truncate, reverse
Filters + defaults — filter applies to the resolved value, default applies when the path is missing:
render("{name|upper:GUEST}", {}); // → "GUEST"
render("{name|upper:GUEST}", { name: "alice" }); // → "ALICE"Custom filters
const renderer = createRenderer({
filters: {
currency: (v) => "$" + Number(v).toFixed(2),
bold: (v) => `<strong>${v}</strong>`,
ellipsis: (v) => (v.length > 20 ? v.slice(0, 20) + "…" : v),
},
});
renderer.render("{price|currency}", { price: "9.99" }); // → "$9.99"
renderer.render("{bio|ellipsis}", { bio: "A very long bio..." }); // → "A very long bi…"Function values
When a data value is a function, it is called automatically:
render("{token}", { token: () => crypto.randomUUID() });
// → "110e8400-e29b-41d4-a716-446655440000"
render("{timestamp}", { timestamp: () => new Date().toISOString() });
// → "2024-01-15T12:30:00.000Z"Custom delimiters
// Per-call
render(
"Hello {{name}}",
{ name: "Alice" },
{ delimiters: { open: "{{", close: "}}" } },
);
// Baked into a renderer (preferred for repeated use)
const mustache = createRenderer({ delimiters: { open: "{{", close: "}}" } });
mustache.render("Hello {{name}}", { name: "Alice" }); // → "Hello Alice"Escaped delimiters
Prefix the open delimiter with \ to emit it literally:
render("Price: \\{amount}", { amount: "99" });
// → "Price: {amount}"
render("\\{raw} and {actual}", { actual: "value" });
// → "{raw} and value"HTML escaping (XSS prevention)
render(
"{html}",
{ html: '<script>alert("xss")</script>' },
{ escapeHtml: true },
);
// → "<script>alert("xss")</script>"
// Or baked in
const safe = createRenderer({ escapeHtml: true });
safe.render("{content}", { content: userInput }); // always escapedMissing value modes
// "silent" (default) — return empty string
render("{missing}", {}, { missing: "silent" }); // → ""
// "warn" — return empty string and log a console.warn
render("{missing}", {}, { missing: "warn" }); // → "" + console.warn(...)
// "strict" — throw BracesError
render("{missing}", {}, { missing: "strict" }); // → throws BracesErrorRenderOptions reference
| Option | Type | Default | Description |
| ------------ | -------------------------------- | -------------- | --------------------------------- |
| delimiters | { open, close } | { '{', '}' } | Open/close delimiter pair |
| escapeHtml | boolean | false | Escape & < > " ' \`` |
| missing |'silent' | 'warn' | 'strict'|'silent' | Unresolved placeholder behaviour |
|filters |Record<string, Filter> |{} | Custom filter functions |
|maxDepth |number |10 | Maximum path traversal depth |
|cacheSize |number |512` | LRU cache capacity (0 = disabled) |
Performance
Architecture
render()
│
├─ Fast path: !template.includes(open) → return as-is O(n) string scan
│
├─ Cache hit → compiled fn(data) O(1) LRU lookup
│
└─ Cache miss → scan() → compile() → cache → fn(data) O(template length)
compiled once, reused foreverWhy a hand-written scanner instead of regex?
- No mutable state — regex
lastIndexis mutable and causes subtle bugs when a cached regex is used across concurrent renders. The scanner is a pure function with no shared state. - Precise error positions — the scanner tracks byte offsets, enabling accurate
BracesErrormessages. - SIMD-backed indexOf — finding the close delimiter uses
String.prototype.indexOf, which V8 compiles to SIMD instructions. This is faster than a custom character loop for most delimiter lengths. - Extensible — adding a new token type (comment, conditional) requires a single
ifbranch, not a regex rewrite.
Why O(1) LRU over a plain Map?
A plain Map with "evict oldest key" is O(n) for eviction — Map.keys().next() walks the entire hash chain. A doubly-linked list + Map gives O(1) get, set, and eviction.
Benchmark estimates
| Scenario | ops/sec (est.) |
| ------------------------------------ | -------------- |
| render() — cached, simple template | ~2,800,000 |
| render() — cached, 5 nested paths | ~1,100,000 |
| precompile() call, simple template | ~4,500,000 |
| Fast path (no delimiter in template) | ~12,000,000 |
| First parse (uncached, 10 tokens) | ~220,000 |
Run npm run bench for results on your hardware.
Security
XSS prevention
Enable escapeHtml: true whenever rendering user-supplied values into HTML. Escapes `& < > " ' `` (OWASP minimum + backtick for IE-style attribute injection).
const safe = createRenderer({ escapeHtml: true });
safe.render("{userInput}", { userInput: '"><script>alert(1)</script>' });
// → ""><script>alert(1)</script>"Prototype pollution
Every segment of every path is checked against a blocklist (__proto__, constructor, prototype) at traversal time — not just the root. This closes the {safe.__proto__.evil} attack vector.
render("{__proto__.polluted}", {}); // → ""
render("{safe.__proto__.polluted}", {}); // → "" ← checked at EVERY step
render("{constructor.prototype.evil}", {}); // → ""Path depth limit
The maxDepth option (default: 10) prevents runaway traversal on adversarially long paths:
// 11 segments — silently returns "" with default maxDepth: 10
render("{a.b.c.d.e.f.g.h.i.j.k}", {});No eval, no Function()
Template execution is a plain for loop over a flat token array. No dynamic code generation, no eval, no attack surface. Function values in the data object are called with zero arguments and in no special scope — they cannot access any closed-over state.
SSR with streaming
import { createRenderer } from "@hammadryaz/braces";
import { Readable } from "node:stream";
const renderer = createRenderer({ escapeHtml: true });
// Express.js example
app.get("/page", async (req, res) => {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Transfer-Encoding", "chunked");
const template = await fs.readFile("templates/page.html", "utf8");
const data = await db.getPageData(req.params.id);
// Browser receives the <head> before the <body> data is even queried
for await (const chunk of renderer.stream(template, data)) {
res.write(chunk);
}
res.end();
});TypeScript — advanced usage
Path inference from string literals
When template is a const or inline literal, TypeScript extracts the placeholder root keys and enforces them in the data object:
// TypeScript sees { name?: DataValue } & DataObject
render("Hello {name}", { name: "Alice" }); // ✅
render("Hello {name}", { nmae: "Alice" }); // ❌ 'nmae' not in inferred typeUsing ExtractPaths and InferData
import type { ExtractPaths, InferData } from "@hammadryaz/braces";
type Paths = ExtractPaths<"Hello {user.name}, you have {count:0} items">;
// → "user.name" | "count"
type Data = InferData<"Hello {user.name}, you have {count:0} items">;
// → { user?: DataValue; count?: DataValue } & DataObjectTyped compiled templates
import type { CompiledTemplate } from "@hammadryaz/braces";
const greet: CompiledTemplate<"Hello {name}!"> = precompile("Hello {name}!");
greet({ name: "Alice" }); // ✅Publishing
npm run build # compile ESM + CJS + type declarations
npm test # run 118 tests
npm run typecheck # full strict TypeScript check
npm version patch # bump version
npm publish # runs prepublishOnly automaticallyThe package ships:
dist/esm/— native ES modules (.js) + source maps + declarations (.d.ts)dist/cjs/— CommonJS modules (.cjs) + declarations (.d.cts)- Zero runtime dependencies
Contributing
git clone https://github.com/hammadryaz/braces
cd braces && npm install
npm run test:watch # TDD mode
npm run typecheck # type safety
npm run bench # performancePRs are welcome. Please:
- Write tests for every new behaviour
- Keep bundle size impact minimal — check with
npm run size - Update this README for any API additions
Roadmap
- [ ] Async filter support (
{value|asyncFilter}) - [ ] Conditional blocks
{#if condition}...{/if}(opt-in, tree-shakeable) - [ ] Tagged template literal API
braces`Hello ${name}` - [ ] CDN UMD build for
<script>tag usage - [ ] Plugin system for custom token types
- [ ] Deno and Bun first-class testing
- [ ] Streaming with lazy async data resolution
License
MIT © 2026 hammadryaz
