Guide
Type-safe prompt template library for TypeScript. A 2KB primitive 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.
Installation
pnpm add @nkwib/tprompt
# Optional, only if you want runtime validation:
pnpm add zod engines.node is >= 20. sideEffects: false is honoured by all modern bundlers (Vite, esbuild, webpack 5+, Rollup) — the 2KB pitch holds at consumer level.
Quick start
import { 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 type Try 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." The intentional missing
.partial(...).partial(...)chain prevents an entire family of "I bound it twice and forgot what was where" bugs at type-check time. If you need composition, prefer.partial()once and pass the rest to.with({...}).
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 snippet 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.
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.
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./single-brace": {
"import": "./dist/single-brace.js",
"require": "./dist/single-brace.cjs",
"types": "./dist/single-brace.d.ts"
}
} 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.
Where next
- API reference — every method signature and the
Compiledinterface. - Glossary — canonical terms used throughout the codebase and docs.
- Vercel AI SDK example — see tprompt replace a silent typo bug in a real call.
- Decisions — the three ADRs covering delimiter choice, parser pluggability, and ESM/CJS interop.