@nkwib/tprompt
v0.2.0
Published
Type-safe prompt template library for TypeScript. A small (~1.4KB gzipped) primitive that turns prompt placeholder typos into tsc errors.
Maintainers
Readme
tprompt
Type-safe prompt template library for TypeScript. A small primitive (~6.5KB unminified, ~1.4KB gzipped) that turns prompt placeholder typos into tsc errors before they reach the model.
tprompt is variables only, no template logic — no {{#if}}, no loops, no DSL. If you need conditionals or iteration, build them in TypeScript and pass strings.
Quick start
pnpm add @nkwib/tprompt
# Optional, only if you want runtime validation:
pnpm add zodimport { prompt } from '@nkwib/tprompt';
const greet = prompt('Hello, {{name}}!');
console.log(greet.with({ name: 'world' }));
// "Hello, world!"
// Typo? `tsc` flags it before the program runs:
greet.with({ nme: 'world' });
// ^^^^ Property 'name' is missing in typeTry it in the TS Playground — rename {{usrName}} back to {{userName}} to clear the error.
Multi-turn: .partial({...})
Pre-bind a subset; the rest get supplied later. Partials don't compose — the return type drops .partial, so .partial(...).partial(...) is a tsc error.
const support = prompt('You are a {{role}} agent for {{userName}}.');
const adminSupport = support.partial({ role: 'support' });
adminSupport.with({ userName: 'alice' });
// "You are a support agent for alice."Pluggable delimiter
The default prompt is makePromptTag({ open: '{{', close: '}}' }). The factory ships from day one for two cases: collisions (a meta-prompt that itself contains {{...}} content) and ecosystem porters (LangChain / BAML / OpenAI's prompt cookbook all use {{var}}, but f-string-style projects use {var}).
// Bring your own delimiter:
import { makePromptTag } from '@nkwib/tprompt';
const angle = makePromptTag({ open: '<<', close: '>>' });
angle('Hi <<name>>').with({ name: 'world' });
// "Hi world"// Pre-applied {var} variant:
import { prompt } from '@nkwib/tprompt/single-brace';
prompt('Hi {name}').with({ name: 'world' });
// "Hi world"{var} is opt-in only. The default uses {{var}} because LLM prompts routinely contain literal JSON ({"name": "alice"}) and a single-brace parser would silently match identifiers inside that content. See ADR-0001 for the full reasoning.
ESM / CJS
Transparent — the same import (or require) of '@nkwib/tprompt' resolves to the right bytes per environment via conditional exports. There is no tprompt/compat subpath; module-system interop is handled invisibly. See ADR-0003.
engines.node is >= 20. sideEffects: false is honoured by all modern bundlers (Vite, esbuild, webpack 5+, Rollup) — the small-bundle pitch (~1.4KB gzipped) holds at consumer level.
Runtime validation: .validate() and .validateSafe()
Two modes, by design.
import { z } from 'zod';
// Throws on invalid input. The default. Errors are model-side bugs;
// crashing loud at the boundary keeps them out of production prompts.
const greet = prompt('Hi {{name}}').validate(
z.object({ name: z.string().min(2) })
);
greet.with({ name: 'a' }); // throws ZodError
// Returns a Result discriminated union. Use this when you're handling
// user input that *might* be wrong, and you want the failure as a value.
const safe = prompt('Hi {{name}}').validateSafe(
z.object({ name: z.string().min(2) })
);
const result = safe.with({ name: 'a' });
if (result.ok) {
console.log(result.value);
} else {
console.error(result.error);
}These two methods are the hardest API to explain — read the section above slowly. The default is .validate() (throws). Reach for .validateSafe() only when the failure is a value you want to inspect. Mixing them produces dead code; pick one per call site.
zod is an optional peer dependency — tprompt accepts any object with .parse(value) and .safeParse(value) shape, so Valibot, ArkType, or your own validator all work. The library never imports zod at module load; the validation surface is structural.
Missing keys throw
If .with({...}) is called with a placeholder name absent from the supplied
object (typically because TypeScript was bypassed via as or any), tprompt
throws MissingPlaceholderError rather than silently rendering the literal
string "undefined" into a prompt sent to a model.
import { prompt, MissingPlaceholderError } from '@nkwib/tprompt';
const t = prompt('Hi {{name}}');
const cast = t.with as (v: Record<string, unknown>) => string;
cast({}); // throws MissingPlaceholderError: missing placeholder value(s): "name"If you depend on the legacy behavior, opt back in via the factory:
import { makePromptTag } from '@nkwib/tprompt';
const lenient = makePromptTag({
open: '{{',
close: '}}',
onMissing: 'insert-undefined'
});Explicit undefined values still render through String(undefined) in both
modes; only absent keys trigger the throw.
Non-goals
tprompt is variables only. No template logic, no expression placeholders, no DSL.
- No
{{#if}},{{#each}}, conditionals, or loops. - No expressions inside placeholders (
{{ user.name }}is not a placeholder). - No nested placeholders (
{{ {{inner}} }}is not supported).
If you need any of the above, build it in TypeScript and pass strings into .with({...}). Pull requests that introduce template logic, expression syntax, or scope-creep beyond a single identifier will be closed with a link to ADR-0001 and the non-goals section above.
Documentation
CONTEXT.md— canonical glossary; terms used in code-level naming- ADR-0001 — default delimiter is
{{var}} - ADR-0002 — single generic + factory + pre-applied subpath exports
- ADR-0003 — ESM-source dual-publish, the five non-negotiable invariants
License
MIT — see LICENSE.
