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 Compiled interface.
  • 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.
tprompt Type-safe prompt templates for TypeScript