Skip to content

Interpolatation

The interpolate module is Spry’s template engine that handles variable substitution, dynamic content generation, and reusable content fragments. It provides both secure (safe) and powerful (unsafe) interpolation modes, giving you the flexibility to choose the right tool for your use case.

When writing runbooks and automation, you often need to:

  • Inject configuration values: Database URLs, API keys, environment-specific settings
  • Generate dynamic content: File names with timestamps, computed values
  • Reuse content: Common SQL snippets, shared configuration blocks
  • Compose templates: Build complex outputs from smaller pieces

Interpolate provides a unified system for all these needs with two key modes:

  1. Safe Mode: Fast, secure variable substitution (like ${VAR} expansion)
  2. Unsafe Mode: Full JavaScript execution in templates (for trusted sources)
lib/interpolate/
├── safe.ts # Safe interpolation engine (no code execution)
├── safe_test.ts # Safe mode tests
├── unsafe.ts # Unsafe interpolation (JavaScript evaluation)
├── unsafe_test.ts # Unsafe mode tests
├── partial.ts # Reusable content fragments system
├── partial_test.ts # Partial tests
├── capture.ts # Variable extraction utilities
├── capture_test.ts # Capture tests
└── mod_test.ts # Integration tests

Choosing between safe and unsafe interpolation is about trust and capability:

AspectSafeUnsafe
Code executionNo eval, no Function()Full JavaScript execution
Use caseConfig files, user input, environment varsTrusted templates, complex logic
PerformanceFast (no compilation)Slower (compiles JS)
SecurityHigh (no injection risk)Requires trusted input
ExpressionsMini language (paths, functions)Full JavaScript
When to useDefault choice, external inputInternal templates only

Rule of thumb: Use safe by default. Only use unsafe when you control the template source and need JavaScript’s full power.

Safe interpolation uses a mini expression language that supports common operations without code execution.

import { safeInterpolate } from "./safe.ts";
// Simple variables
const result = safeInterpolate(
"Hello ${name}!",
{ name: "World" }
);
// Output: "Hello World!"
// Nested objects
const result2 = safeInterpolate(
"Database: ${db.host}:${db.port}",
{ db: { host: "localhost", port: 5432 } }
);
// Output: "Database: localhost:5432"
// Array access
const result3 = safeInterpolate(
"First item: ${items[0]}",
{ items: ["apple", "banana", "cherry"] }
);
// Output: "First item: apple"

Safe mode supports multiple delimiter styles:

// Mustache style
const r1 = safeInterpolate(
"Hello {{name}}!",
{ name: "World" },
{
brackets: [{ id: "mustache", open: "{{", close: "}}" }]
}
);
// Dollar style (default)
const r2 = safeInterpolate(
"Hello ${name}!",
{ name: "World" },
{
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }]
}
);
// Custom style
const r3 = safeInterpolate(
"Hello <%name%>!",
{ name: "World" },
{
brackets: [{ id: "custom", open: "<%", close: "%>" }]
}
);

Safe mode’s mini language supports:

// 1. Property access (dot notation)
"${user.profile.name}"
// 2. Array indexing
"${items[0]}", "${matrix[1][2]}"
// 3. String literals
"${'Hello World'}"
// 4. Number literals
"${42}", "${3.14}"
// 5. Boolean literals
"${true}", "${false}"
// 6. Null literal
"${null}"
// 7. Function calls (with registered functions)
"${upper(name)}", "${join(items, ', ')}"
// 8. Nested backtick templates
"${`nested ${value}`}"

Register custom functions for safe mode:

import { safeInterpolate } from "./safe.ts";
const options = {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
functions: {
// String transformation
upper: ([val]) => String(val).toUpperCase(),
lower: ([val]) => String(val).toLowerCase(),
// Array operations
join: ([arr, sep]) => Array.isArray(arr) ? arr.join(sep || ',') : String(arr),
length: ([val]) => Array.isArray(val) ? val.length : String(val).length,
// Conditionals
default: ([val, defaultVal]) => val ?? defaultVal,
ifelse: ([condition, trueVal, falseVal]) => condition ? trueVal : falseVal,
// Date/time
now: () => new Date().toISOString(),
timestamp: () => Date.now(),
}
};
const result = safeInterpolate(
"Welcome ${upper(user.name)}! Today is ${now()}",
{ user: { name: "alice" } },
options
);
// Output: "Welcome ALICE! Today is 2024-01-15T10:30:00.000Z"

When rendering the same template multiple times, compile it once:

import { compileSafeTemplate, renderCompiledTemplate } from "./safe.ts";
// Compile once
const template = compileSafeTemplate(
"Hello ${name}, you have ${count} items",
{ brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }] }
);
// Render many times (fast!)
const users = [
{ name: "Alice", count: 5 },
{ name: "Bob", count: 12 },
{ name: "Charlie", count: 3 }
];
for (const user of users) {
console.log(renderCompiledTemplate(template, user));
}
// Output:
// Hello Alice, you have 5 items
// Hello Bob, you have 12 items
// Hello Charlie, you have 3 items

Control what happens when variables are missing:

const text = "Hello ${name}, age ${age}";
// Strategy 1: Leave as-is (default)
safeInterpolate(text, { name: "Alice" }, {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
onMissing: "leave"
});
// Output: "Hello Alice, age ${age}"
// Strategy 2: Empty string
safeInterpolate(text, { name: "Alice" }, {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
onMissing: "empty"
});
// Output: "Hello Alice, age "
// Strategy 3: Throw error
try {
safeInterpolate(text, { name: "Alice" }, {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
onMissing: "throw"
});
} catch (e) {
console.error("Missing variable:", e);
}
// Strategy 4: Custom handler
safeInterpolate(text, { name: "Alice" }, {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
onMissing: (expr) => `[MISSING: ${expr}]`
});
// Output: "Hello Alice, age [MISSING: age]"

Unsafe mode executes full JavaScript within templates. Use only with trusted sources.

Good use cases:

  • Internal configuration templates you control
  • Complex calculations requiring JavaScript
  • Dynamic SQL generation from trusted sources
  • Build-time code generation

Bad use cases:

  • User-provided templates
  • External configuration files
  • Any untrusted input
  • Public-facing forms or APIs
import { unsafeInterpolator } from "./unsafe.ts";
// Create interpolator with global context
const { interpolate } = unsafeInterpolator({
app: {
name: "Spry",
version: "1.0.0"
},
env: Deno.env.get("ENV") || "development",
utils: {
upper: (s: string) => s.toUpperCase(),
dateFormat: (d: Date) => d.toISOString().split('T')[0]
}
});
// Template with JavaScript expressions
const template = `
Application: \${ctx.app.name} v\${ctx.app.version}
Environment: \${ctx.env.toUpperCase()}
Today: \${ctx.utils.dateFormat(new Date())}
Computed: \${ctx.app.version.split('.').map(n => parseInt(n) * 2).join('.')}
`;
const result = await interpolate(template, {});
// Output:
// Application: Spry v1.0.0
// Environment: DEVELOPMENT
// Today: 2024-01-15
// Computed: 2.0.0

The global context is bound to a variable name (default: ctx):

// Default context name
const { interpolate } = unsafeInterpolator({
version: "1.0"
});
await interpolate("${ctx.version}", {}); // Access via 'ctx'
// Custom context name
const { interpolate: interp2 } = unsafeInterpolator(
{ version: "1.0" },
{ ctxName: "globals" }
);
await interp2("${globals.version}", {}); // Access via 'globals'
const { interpolate } = unsafeInterpolator({
appName: "Spry",
config: { timeout: 5000 }
});
// Global context in 'ctx', local data passed directly
const result = await interpolate(
"App: ${ctx.appName}, User: ${user}, Timeout: ${ctx.config.timeout}",
{ user: "Alice" } // Local data
);
// Output: "App: Spry, User: Alice, Timeout: 5000"

Since unsafe mode runs JavaScript, you can use any valid expression:

const { interpolate } = unsafeInterpolator({
data: {
items: [10, 20, 30, 40, 50],
threshold: 25
}
});
const template = `
Total: \${ctx.data.items.reduce((a, b) => a + b, 0)}
Average: \${ctx.data.items.reduce((a, b) => a + b, 0) / ctx.data.items.length}
Above threshold: \${ctx.data.items.filter(x => x > ctx.data.threshold).length}
Doubled: \${ctx.data.items.map(x => x * 2).join(', ')}
`;
const result = await interpolate(template, {});
// Output:
// Total: 150
// Average: 30
// Above threshold: 2
// Doubled: 20, 40, 60, 80, 100

Partials are reusable content fragments that can be composed, validated, and injected into other content.

Think of partials as named, reusable templates:

  • Definition: Named content with optional variable substitution
  • Schema: Optional validation for required variables
  • Injectable: Can wrap or be inserted into other content
  • Composable: Partials can reference other partials
import { partialContent, partialContentCollection } from "./partial.ts";
// Create a collection to hold all partials
const collection = partialContentCollection();
// Simple partial (no variables)
const header = partialContent(
"header",
"<header><h1>My Application</h1></header>"
);
collection.register(header);
// Partial with variables
const greeting = partialContent(
"greeting",
"Hello ${name}, welcome to ${app}!"
);
collection.register(greeting);
// Partial with schema validation
const sqlQuery = partialContent(
"select-user",
"SELECT * FROM users WHERE id = ${userId} AND status = '${status}'",
{
schemaSpec: {
userId: { type: "number" },
status: { type: "string" }
}
}
);
collection.register(sqlQuery);
// Render with variables
const result = await collection.render({
identity: "greeting",
locals: { name: "Alice", app: "Spry" }
});
// Output: "Hello Alice, welcome to Spry!"
// Schema validation happens automatically
try {
await collection.render({
identity: "select-user",
locals: { userId: "not-a-number", status: "active" }
});
} catch (error) {
console.error("Validation failed:", error);
// userId must be a number!
}

Injectable partials wrap or enhance other content based on patterns:

// SQL transaction wrapper
const sqlWrapper = partialContent(
"sql-transaction",
"BEGIN TRANSACTION;\n${content}\nCOMMIT;",
{
inject: {
globs: ["*.sql"], // Match .sql files
mode: "both" // Prepend + append
}
}
);
collection.register(sqlWrapper);
// Error handling wrapper
const errorHandler = partialContent(
"error-handler",
"try {\n${content}\n} catch (error) {\n console.error(error);\n}",
{
inject: {
globs: ["*.js", "*.ts"],
mode: "both"
}
}
);
collection.register(errorHandler);
// Header prepend only
const fileHeader = partialContent(
"file-header",
"// Generated at ${timestamp}\n// Do not edit manually\n",
{
inject: {
globs: ["*.ts"],
mode: "prepend"
}
}
);
collection.register(fileHeader);
// Render SQL query with automatic transaction wrapper
const result = await collection.renderWithInjection({
identity: "select-user",
path: "query.sql", // Matches *.sql glob
locals: {
userId: 123,
status: "active",
timestamp: new Date().toISOString()
}
});
// Output:
// BEGIN TRANSACTION;
// SELECT * FROM users WHERE id = 123 AND status = 'active'
// COMMIT;

Partials can reference other partials:

// Base partials
const logo = partialContent("logo", "<img src='logo.png'>");
const nav = partialContent("nav", "<nav>Home | About</nav>");
const footer = partialContent("footer", "<footer>© 2024</footer>");
// Composite partial
const layout = partialContent(
"page-layout",
`
<!DOCTYPE html>
<html>
<head><title>\${title}</title></head>
<body>
\${partial:logo}
\${partial:nav}
<main>\${content}</main>
\${partial:footer}
</body>
</html>
`
);
collection.register(logo);
collection.register(nav);
collection.register(footer);
collection.register(layout);
// Render with all partials expanded
const page = await collection.render({
identity: "page-layout",
locals: {
title: "Welcome",
content: "<h1>Hello World</h1>"
}
});

Extract variable names from templates without rendering:

import { captureVariables } from "./capture.ts";
// Extract all variable names
const vars1 = captureVariables("Hello ${name}, you have ${count} items");
// Output: ["name", "count"]
// Works with nested paths
const vars2 = captureVariables("DB: ${db.host}:${db.port}");
// Output: ["db.host", "db.port"]
// Works with different delimiters
const vars3 = captureVariables(
"Hello {{name}}!",
{ open: "{{", close: "}}" }
);
// Output: ["name"]

Use cases:

  • Validate that all required variables are provided
  • Generate documentation of template dependencies
  • Build configuration UIs
  • Detect unused variables

The safe interpolation engine. Contains the mini expression parser and renderer.

Key exports:

  • safeInterpolate(template, data, options?) - One-shot interpolation
  • compileSafeTemplate(template, options?) - Compile for reuse
  • renderCompiledTemplate(compiled, data) - Render compiled template

Configuration options:

  • brackets - Delimiter configuration
  • functions - Custom function registry
  • onMissing - Missing value strategy
  • escape - Output escaping function
  • maxDepth - Recursion limit (default: 5)

The unsafe interpolation engine with JavaScript evaluation.

Key exports:

  • unsafeInterpolator(context, options?) - Create interpolator
  • unsafeInterpFactory(options) - Factory with partials

Security notes:

  • Uses Function() constructor for evaluation
  • Context is bound to configurable name (default: ctx)
  • NEVER use with untrusted input

Reusable content fragments with validation and injection.

Key exports:

  • partialContent(identity, content, options?) - Create partial
  • partialContentCollection() - Create collection
  • Collection methods: register(), render(), renderWithInjection()

Partial options:

  • schemaSpec - Zod validation schema
  • inject.globs - File patterns to match
  • inject.mode - ‘prepend’, ‘append’, or ‘both’

Variable extraction utilities.

Key exports:

  • captureVariables(template, options?) - Extract variable names

Example 1: Environment-Specific Configuration

Section titled “Example 1: Environment-Specific Configuration”
import { safeInterpolate } from "./safe.ts";
// Configuration template
const configTemplate = `
{
"database": {
"host": "${DB_HOST}",
"port": ${DB_PORT},
"name": "${DB_NAME}",
"ssl": ${DB_SSL}
},
"api": {
"url": "${API_URL}",
"timeout": ${API_TIMEOUT}
}
}
`;
// Development environment
const devEnv = {
DB_HOST: "localhost",
DB_PORT: 5432,
DB_NAME: "dev_db",
DB_SSL: false,
API_URL: "http://localhost:8000",
API_TIMEOUT: 5000
};
// Production environment
const prodEnv = {
DB_HOST: "prod.db.example.com",
DB_PORT: 5432,
DB_NAME: "production",
DB_SSL: true,
API_URL: "https://api.example.com",
API_TIMEOUT: 10000
};
const options = {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }]
};
const devConfig = safeInterpolate(configTemplate, devEnv, options);
const prodConfig = safeInterpolate(configTemplate, prodEnv, options);
import { partialContentCollection, partialContent } from "./partial.ts";
const sql = partialContentCollection();
// Common SQL patterns
sql.register(partialContent(
"pagination",
"LIMIT ${limit} OFFSET ${offset}",
{ schemaSpec: { limit: { type: "number" }, offset: { type: "number" } } }
));
sql.register(partialContent(
"timestamp-filter",
"created_at >= '${startDate}' AND created_at < '${endDate}'",
{ schemaSpec: { startDate: { type: "string" }, endDate: { type: "string" } } }
));
sql.register(partialContent(
"audit-columns",
"created_at, updated_at, created_by, updated_by"
));
// Composite query
sql.register(partialContent(
"list-users",
`
SELECT id, username, email, ${partial:audit-columns}
FROM users
WHERE ${partial:timestamp-filter}
${partial:pagination}
`
));
// Render complete query
const query = await sql.render({
identity: "list-users",
locals: {
startDate: "2024-01-01",
endDate: "2024-02-01",
limit: 50,
offset: 100
}
});
import { unsafeInterpolator } from "./unsafe.ts";
const { interpolate } = unsafeInterpolator({
project: {
name: "my-app",
version: "2.1.0",
environments: ["dev", "staging", "prod"]
},
helpers: {
gitTag: (version: string) => `v${version}`,
deployCmd: (env: string, version: string) =>
`./deploy.sh --env ${env} --version ${version}`
}
});
const runbookTemplate = `
# Deploy ${ctx.project.name}
## Version: ${ctx.project.version}
${ctx.project.environments.map(env => `
### Deploy to ${env.toUpperCase()}
\`\`\`bash
# Tag release
git tag ${ctx.helpers.gitTag(ctx.project.version)}
git push origin ${ctx.helpers.gitTag(ctx.project.version)}
# Deploy
${ctx.helpers.deployCmd(env, ctx.project.version)}
# Verify
curl https://${env}.example.com/health
\`\`\`
`).join('\n')}
`;
const runbook = await interpolate(runbookTemplate, {});

Axiom uses interpolate for code fence content:

import { interpolateUnsafely } from "../interpolate/unsafe.ts";
// Interpolate task content before execution
const result = await interpolateUnsafely({
source: taskNode.content,
interpolate: true
});

SQLPage uses interpolate for SQL template rendering:

import { sqlPageInterpolate } from "../sqlpage/interpolate.ts";
// Render SQL templates with partials
const sql = sqlPageInterpolate(template, context);

Task execution leverages interpolation for dynamic commands:

// Before execution, interpolate environment variables
const command = safeInterpolate(
task.command,
{ ...Deno.env.toObject(), ...taskContext }
);

Markdown notebooks use interpolation for cell content:

import { renderCell } from "../markdown/notebook/cell.ts";
// Cell content is interpolated before rendering
const output = await renderCell(cell, interpolationContext);
// GOOD: Safe for user input
const userGreeting = safeInterpolate(
"Hello ${username}!",
{ username: userInput }
);
// BAD: Unsafe with user input
const unsafe = unsafeInterpolator({});
await unsafe.interpolate(
`Hello ${userInput}!`, // DANGER: Code injection!
{}
);
// GOOD: Schema catches errors early
const query = partialContent(
"get-user",
"SELECT * FROM users WHERE id = ${id}",
{ schemaSpec: { id: { type: "number" } } }
);
// BAD: No validation
const query2 = partialContent(
"get-user",
"SELECT * FROM users WHERE id = ${id}" // Any type accepted
);
// GOOD: Compile once, render many times
const template = compileSafeTemplate("Hello ${name}!");
for (const user of users) {
renderCompiledTemplate(template, user);
}
// BAD: Reparse every time
for (const user of users) {
safeInterpolate("Hello ${name}!", user);
}
// GOOD: DRY with partials
const header = partialContent("header", "...");
const footer = partialContent("footer", "...");
// Use in multiple templates
// BAD: Copy-paste everywhere
const page1 = "<!-- header -->" + content1 + "<!-- footer -->";
const page2 = "<!-- header -->" + content2 + "<!-- footer -->";
// GOOD: Set reasonable limit
safeInterpolate(template, data, {
brackets: [{ id: "dollar", prefix: "$", open: "{", close: "}" }],
maxDepth: 3 // Prevent infinite loops
});
Terminal window
# Run all interpolate tests
deno test lib/interpolate/
# Run specific mode tests
deno test lib/interpolate/safe_test.ts
deno test lib/interpolate/unsafe_test.ts
deno test lib/interpolate/partial_test.ts
# Run with coverage
deno test --coverage=coverage/ lib/interpolate/

Safe mode is designed to prevent code injection:

  • No eval() or Function() calls
  • No property access beyond data object
  • Limited expression language
  • Safe for user-provided templates

Unsafe mode executes arbitrary JavaScript:

  • Only use with trusted template sources
  • Never use with user input or external data
  • Consider sandboxing or alternative approaches
  • Audit all templates that use unsafe mode

When in doubt, use safe mode.

  1. Safe by default: Use safeInterpolate unless you need JavaScript
  2. Partials for reuse: Build a library of reusable content fragments
  3. Compile for performance: Pre-compile templates used multiple times
  4. Validate with schemas: Catch errors early with partial schemas
  5. Never trust user input: Only use unsafe mode with controlled sources