npm.io
0.4.0 • Published yesterday

md-tmpl

Licence
MIT OR Apache-2.0
Version
0.4.0
Deps
0
Size
727 kB
Vulns
0
Weekly
0

md-tmpl

Strongly-typed prompt templates for LLMs.

The Cool Part

Define a prompt as a .tmpl.md file — readable markdown with typed frontmatter:

---
consts:
  - MAX_RETRIES = int := 3

types:
  - Priority = enum(High, Medium, Low)

params:
  - role = str
  - tasks = list(title = str, priority = Priority)
  - outcome = enum(Confirmed(evidence = str), Rejected)
---

You are {{ role }}. You have {{ len(tasks) }} tasks:

> {% for task in tasks %}

- **{{ task.title }}** ({{ task.priority | upper }})

> {% /for %}

> {% match outcome %}
> {% when Confirmed %}

✅ Confirmed — {{ outcome.evidence }}

> {% when Rejected %}

❌ Rejected. Retry up to {{ MAX_RETRIES }} times.

> {% /match %}

Generate TypeScript types from that file, then render with full type safety:

import type { Params } from "./generated/agent_task.js";
import { TypedTemplate, defineVariants, match } from "md-tmpl";

const tmpl = TypedTemplate.fromFile<Params>("prompts/agent_task.tmpl.md");

const Outcome = defineVariants({
  Confirmed: ["evidence"],
  Rejected: null,
});

const result = tmpl.render({
  role: "a code review agent",
  tasks: [
    { title: "Check error handling", priority: "High" },
    { title: "Verify test coverage", priority: "Medium" },
  ],
  outcome: Outcome.Confirmed({ evidence: "All edge cases covered" }),
});

// Pattern-match the outcome later
const summary = match(outcome, {
  Confirmed: (f) => `✅ ${f.evidence}`,
  Rejected: () => "❌ Needs revision",
});

The generated types look like this — interfaces, enums, constants, and defaults:

export interface TasksItem {
  readonly title: string;
  readonly priority: Priority;
}

export type Priority = "High" | "Medium" | "Low";

export interface Outcome_Confirmed {
  readonly __kind__: "Confirmed";
  readonly evidence: string;
}

export type Outcome = Outcome_Confirmed | "Rejected";

export interface Params {
  readonly role: string;
  readonly tasks: readonly TasksItem[];
  readonly outcome: Outcome;
}

export const CONSTANTS = {
  MAX_RETRIES: 3,
} as const;

Why?

  • Markdown-native — prompts live in .tmpl.md files, readable in any editor or on GitHub. No embedded strings, no escaping.
  • Strict typing — every parameter declares a type; generateTypes() emits TypeScript interfaces from frontmatter. Catch errors before deployment, not at 3 AM in production.
  • Agent-safe — when an LLM edits prompts, the engine catches drift immediately. Renamed a field? Changed a type? Broke the contract? You'll know before it ships.

Installation

npm install md-tmpl

Type Generation

generateTypes() turns frontmatter into full TypeScript — interfaces, enums, constants, and defaults. Run it as a build step:

import { generateTypesFromFile } from "md-tmpl";
import * as fs from "node:fs";

fs.writeFileSync(
  "src/generated/agent_task.ts",
  generateTypesFromFile("prompts/agent_task.tmpl.md"),
);

Or generate from a raw source string:

import { generateTypes } from "md-tmpl";

const code = generateTypes(`---
consts:
  - MAX_RETRIES = int := 3

params:
  - message = str
  - level = int := 1
  - tasks = list(title = str, priority = str)
  - outcome = enum(Confirmed(evidence = str), Rejected)
---
{{ message }}`);

Output includes Params, item interfaces, enum unions, CONSTANTS, and DEFAULTS — ready to import.

Enum Dispatch

defineVariants creates type-safe enum constructors. match gives you exhaustive pattern matching. isVariant is a type guard.

import { defineVariants, match, isVariant } from "md-tmpl";

const Status = defineVariants({
  Done: ["summary"],
  InProgress: null,
  Blocked: ["reason"],
});

// Construct
const status = Status.Done({ summary: "All tests pass" });

// Pattern match — exhaustive over all variants
const msg = match(status, {
  Done: (f) => `✅ ${f.summary}`,
  InProgress: () => "🔄 Working...",
  Blocked: (f) => `❌ ${f.reason}`,
});

// Wildcard fallback
const simple = match(status, {
  Done: (f) => f.summary as string,
  _: () => "pending",
});

// Type guard
if (isVariant(status, "Done")) {
  console.log("Completed!");
}

Features

Typed Lists
const tmpl = Template.fromSource(`---
params:
  - tasks = list(title = str, priority = str)
---
> {% for task in tasks %}

- {{ task.title }}: {{ task.priority }}

> {% /for %}`);

tmpl.render({
  tasks: [
    { title: "Write documentation", priority: "High" },
    { title: "Add unit tests", priority: "Medium" },
  ],
});
Default Values
const tmpl = Template.fromSource(`---
params:
  - greeting = str := "Hello"
  - name = str
---
{{ greeting }}, {{ name }}!`);

tmpl.render({ name: "Alice" }); // → "Hello, Alice!"
tmpl.defaults(); // → { greeting: "Hello" }
If/Elif/Else
> {% if level == 1 %}

Beginner: {{ name }}

> {% elif level == 2 %}

Intermediate: {{ name }}

> {% else %}

Expert: {{ name }}

> {% /if %}
Filters
{{ name | upper }}        → ALICE
{{ name | lower }}        → alice
{{ name | trim }}         → (strips whitespace)
{{ score | fixed(2) }}    → 3.14
{{ items | join(", ") }}  → a, b, c
{{ items | limit(2) }}    → first 2 elements
{{ count | add(1) }}      → 43
{{ count | sub(1) }}      → 41
{{ name | trim | upper }} → chains work
Built-in Functions
{{ len(items) }}          → 3 (list length)
{{ len(name) }}           → 5 (string length)
{{ idx(item) }}           → 0, 1, 2, … (loop index)
{{ kind(status) }}        → "Done" (variant name)
{{ has(field) }}          → true if option(T) is present

API Reference

Template
// Constructors
Template.fromSource(source: string): Template
Template.fromSourceAllowingUnused(source): Template
Template.fromSourceWithBaseDir(source, dir): Template
Template.fromFile(path: string): Template

// Rendering
tmpl.render(params, options?)        // type-validated
tmpl.renderUnchecked(params)         // skip validation (fastest)
tmpl.renderDict(params, options?)    // from Map or Record
// options: { allowExtra?: boolean }

// Metadata
tmpl.declarations()                  // → [["name", "str"], ["count", "int"]]
tmpl.sourceHash()                    // content hash
tmpl.body()                          // template body after frontmatter
tmpl.defaults()                      // → { count: 5 }
tmpl.consts()                        // → { MAX_RETRIES: 3 }
tmpl.frontmatter                     // parsed frontmatter
tmpl.setMaxIncludeDepth(depth)
tmpl.validateDeclarationsAgainst(expected)
TypedTemplate<P>
TypedTemplate.fromSource<P>(source): TypedTemplate<P>
TypedTemplate.fromFile<P>(path): TypedTemplate<P>

tmpl.render(params: P)              // TS type-checked + runtime validated
tmpl.renderUnchecked(params: P)     // skip runtime validation, trust TS types
tmpl.renderTrusted(params: P)       // validate first call, skip rest
TemplateCache
const cache = new TemplateCache();
const tmpl = cache.load("prompts/greeting.tmpl.md");
cache.templateCount();
cache.clear();
ITemplate Interface

Both Template and the WASM Template implement ITemplate. Write backend-agnostic code:

import type { ITemplate } from "md-tmpl";

function renderGreeting(tmpl: ITemplate, name: string): string {
  return tmpl.render({ name });
}

Performance

Internal Benchmarks

Node.js 22, single-template timings (lower is better):

Operation Scenario Time
render simple 610 ns
multi-param 1,808 ns
list (2 items) 4,135 ns
list (20 items) 32,114 ns
enum unit 742 ns
enum struct 1,925 ns
filters 6,939 ns
conditional 2,011 ns
renderUnchecked simple 489 ns
multi-param 1,064 ns
list (2 items) 1,910 ns
conditional 1,134 ns
parse simple 5,112 ns
multi-param 13,442 ns

renderUnchecked() skips runtime type validation — use it when TypeScript's static checks are sufficient.

Comparison Benchmarks

Node.js 22, 50,000 iterations, best of 5 runs (source).

Render only (pre-parsed template + data → output):

Scenario render() renderUnchecked() Handlebars Mustache
simple 1,228 ns 768 ns 1,274 ns 821 ns
loop (5 items) 6,083 ns 2,469 ns 2,093 ns 1,862 ns
conditional 1,974 ns 1,372 ns 1,416 ns 456 ns

Round-trip (parse + render):

Scenario md-tmpl Handlebars Mustache
simple 9,958 ns 88,522 ns 804 ns
loop (5 items) 30,717 ns 125,384 ns 1,897 ns
conditional N/A N/A N/A

Note: Mustache wins raw render speed due to its minimal feature set — no type checking, no filters, no elif. md-tmpl wins renderUnchecked() for simple templates. The value proposition is type safety and correctness, not raw throughput.

A WASM build (~200 KB .wasm) is also available for exact feature parity. Pure TypeScript is 2–6× faster for small templates due to JSWASM serialization overhead; WASM closes the gap on complex templates.

just bench-ts           # internal benchmarks
just bench-ts-compare   # vs Handlebars & Mustache

Testing

just test-ts    # 486 tests
just lint-ts    # strict type-check with tsc
just fmt-ts     # format with prettier

Full Reference

See SPEC.md for the complete syntax reference.

License

Apache-2.0 OR MIT