Type-safe tagged template literals for TypeScript. Declare slot names and types inline, get compile-time checking and a small runtime renderer — no separate schema file.
import { lit } from "@samrith/lit";
const greet = lit`Hello, ${"name"}! You are ${"age?:number=18"} years old.`;
greet({ name: "Ada" });
// => "Hello, Ada! You are 18 years old."
greet({ name: "Ada", age: 42 });
// => "Hello, Ada! You are 42 years old."bun add @samrith/litOr copy lit.js into your project — the library is a single file with no runtime dependencies.
String templates often mix static text with dynamic values. Common approaches leave gaps:
- Plain template literals — no named parameters, no reuse, no validation.
- Separate schemas — types and defaults live far from the string they describe.
- Untyped helpers — easy to pass the wrong key or type at runtime.
Lit keeps the template and its contract in one place. Slot specs like "name" or "name:string" sit in the template literal; TypeScript infers the parameter object; optional slots and defaults are first-class.
import { lit } from "@samrith/lit";
// Required string slot (type omitted → string)
const title = lit`Issue #${"id:number"}: ${"title"}`;
title({ id: 42, title: "Fix the build" });
// => "Issue #42: Fix the build"
// Optional string slot (omitted → empty string)
const line = lit`Status: ${"status?:"}`;
line({});
// => "Status: "
// Optional slot with default
const meta = lit`Version ${"version?:=0.0.0"}`;
meta({});
// => "Version 0.0.0"Place slot descriptors in ${...} inside a lit tagged template. Each descriptor is a string with this shape:
| Form | Meaning |
|---|---|
name |
Required string (type omitted) |
name?: |
Optional string (type omitted) |
name?:=default |
Optional string with default |
name:string |
Required string |
name:number |
Required number |
name:boolean |
Required boolean |
name?:string |
Optional; undefined / null → "" |
name?:number=42 |
Optional with default when missing |
Rules:
- Omit the type to default to
string— usenameorname?:instead ofname:string/name?:string. - When a type is given, it must be exactly
string,number, orboolean. - Defaults require the optional marker:
name?:=defaultorname?:type=default(notname:type=default). - Default literals are parsed at template definition time (
true/falsefor booleans, numeric literals for numbers, raw text for strings).
Invalid specs throw when the template is created:
lit`${"name:object"}`; // Error: invalid type
lit`${"name:string=hi"}`; // Error: default without ?
lit`${"name?:number=hi"}`; // Error: invalid number defaultTagged template function. Returns a renderer function (and supports nesting — see below).
const template = lit`Hello ${"name"}!`;
// Call with parameters
template({ name: "World" }); // "Hello World!"Parameter typing: If every slot is optional (or the template has no slots), the renderer accepts an optional argument. If any slot is required, the argument is required and TypeScript lists the exact keys.
Exported type for the return value:
import type { LitFn } from "@samrith/lit";
const fn: LitFn<{ name: string }> = lit`${"name"}`;Extract the parameter object type from a lit template (or any LitFn):
import { lit, type ExtractParams } from "@samrith/lit";
const tmpl = lit`Hello ${"name"}! You are ${"age?:number=18"} years old.`;
type Params = ExtractParams<typeof tmpl>;
// { name: string; age?: number | null }Useful when you want to reuse the inferred shape elsewhere — function arguments, context types, or validation schemas — without duplicating the slot definitions.
Another lit template can be used as a slot value. Nested templates share the parent’s parameter object.
const emphasis = lit`<strong>${"text:string"}</strong>`;
const card = lit`<div>${emphasis} — ${"label:string"}</div>`;
card({ text: "Hi", label: "Note" });
// => "<div><strong>Hi</strong> — Note</div>"Nested renderers receive the same params object as the outer template, so keys must not collide unless you intend to reuse a value.
- Static string segments are concatenated in order.
- Each slot is resolved from the params object:
- Required / present value — coerced with
String(value). undefinedornullon optional slot — default if set, otherwise"".
- Required / present value — coerced with
- Nested
litfunctions run with the same params.
Lit merges parameters from all slots in a template (and from nested templates) into one object type:
- Required keys must be supplied when calling the renderer.
- Optional keys may be omitted or set to
null. - Invalid slot strings are rejected at compile time via
ValidSlotSpec.
Thrown when the template is defined, not when it is rendered:
| Situation | Message (summary) |
|---|---|
| Malformed slot string | Expected name, name?:, name:type, name?:type, or name?:type=default |
| Default on required slot | Defaults require ? |
| Unknown type | Expected string, number, or boolean |
| Invalid boolean/number default | Invalid default for type |
const subject = lit`[${"project"}] ${"action"}: ${"item"}`;
subject({
project: "lit",
action: "PR",
item: "README",
});
// => "[lit] PR: README"const banner = lit`Welcome${"name?:"}!`;
banner({}); // "Welcome!"
banner({ name: ", Ada" }); // "Welcome, Ada!" (leading comma in value if desired)const whereId = lit`id = ${"id:number"}`;
const query = lit`SELECT * FROM users WHERE ${whereId}`;
query({ id: 1 });
// => "SELECT * FROM users WHERE id = 1"# Install dev dependencies
bun install
# Type-check
bunx tsc --noEmit lit.tsThere is no build step: ship lit.ts as source or wire it into your bundler’s TypeScript pipeline.
MIT © Samrith Shankar