Tagged template literals: limitations & proposed alternative
Background
ReScript v11.1 introduced two mechanisms for working with JavaScript tagged template literals:
- Native ReScript tag functions — any function with the signature
(array<string>, array<'param>) => 'output can be used with backtick syntax. Compiles to a plain function call.
@taggedTemplate decorator on external — for binding to JS tag functions. Compiles to real JS tagged-template syntax at call sites, so JS-side tooling that introspects the literal (gql, sql, css, prettier plugins, syntax highlighting) keeps working.
Several real-world JS libraries — most notably postgres — cannot be used through either mechanism. This issue documents the limitations and proposes an alternative.
Problem 1 — Cannot bind to a tag function constructed at runtime
postgres does not export a tag function. The default export is a factory; the value it returns is the tag:
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL);
const users = await sql`SELECT * FROM users WHERE id = ${userId}`;
@taggedTemplate only attaches to external bindings, which point at statically-exported names. There is no syntax for "this runtime value is a tagged-template tag", so postgres cannot be bound directly.
Problem 2 — The "re-export from raw JS" workaround silently breaks
The usual workaround is to construct the client in a small JS file under a static export, and bind to that:
// sql_client.js
import postgres from "postgres";
export const sql = postgres(process.env.DATABASE_URL);
type queryResult = {rows: array<string>}
@module("./sql_client.js") @taggedTemplate
external sql: (array<string>, array<'a>) => promise<queryResult> = "sql"
2a. Same-module usage works
let run = async () => {
let _ = await sql`SELECT * FROM users WHERE id = ${userId}`
}
Compiled JS:
import * as Sql_clientJs from "./sql_client.js";
function sql(prim0, prim1) {
return Sql_clientJs.sql(prim0, ...prim1);
}
async function run() {
await Sql_clientJs.sql`SELECT * FROM users WHERE id = ${42}`;
}
The call site emits a real tagged-template literal. But the compiler also emits a wrapper (function sql) that does a variadic spread, and that wrapper is what gets exported. Anything importing sql from this module gets the wrapper, not the tag.
2b. Cross-module usage falls back to a plain function call
// In some other module
let run = async () => {
let _ = await SqlBinding.sql`SELECT * FROM users WHERE id = ${userId}`
}
Compiled JS:
import * as SqlBinding from "./SqlBinding.jsx";
async function run() {
await SqlBinding.sql([`SELECT * FROM users WHERE id = `, ``], [7]);
}
This is not a tagged template literal. postgres enforces tagged-template invocation (it relies on strings.raw and identity caching of the TemplateStringsArray) and rejects this at runtime.
The compiler can only emit real tagged-template syntax when the @taggedTemplate external is in scope as the external itself. Once it crosses a module boundary, the consumer only sees the wrapper.
2c. Same problem when the tag flows through any value
let runThroughParam = async tag => {
let _ = await tag`SELECT * FROM users WHERE id = ${userId}`
}
runThroughParam(sql)
Compiled JS:
async function runThroughParam(tag) {
await tag([`SELECT * FROM users WHERE id = `, ``], [42]);
}
The moment the tag is passed as a value, every downstream call uses variadic spread.
Problem 3 — Native ReScript tag functions never emit tagged-template syntax
A tag function defined in pure ReScript (no decorator) always compiles to a plain function call:
let s = (strings, parameters) => { /* ... */ }
let greeting = s`hello ${S("Ada")} you're ${I(36)} years old!`
let greeting = s(
[`hello `, ` you're `, ` years old!`],
[
{ TAG: "S", _0: "Ada" },
{ TAG: "I", _0: 36 },
],
);
So a ReScript-authored wrapper around postgres (e.g. one converting typed parameters before delegating) cannot itself be used as a tag.
Summary
| # |
Limitation |
Consequence |
| 1 |
@taggedTemplate requires a static export. |
Cannot bind to factory-returning-tag libraries (postgres and similar). |
| 2 |
The re-export workaround emits a wrapper that loses tagged-template semantics. |
Cross-module use and any first-class use silently degrade to a plain function call. |
| 3 |
Native ReScript tag functions never emit tagged-template syntax. |
Cannot author a typed ReScript wrapper around a JS tag function. |
Proposed alternative — make "tagged-template tag" a first-class type
The root cause of every limitation above is that tagged-templateness lives on the binding site rather than the type of the value. The moment the value is exported, imported, aliased, passed as a parameter, or returned from a factory, the compiler loses track of it.
The proposal is to make tagged-templateness a property of the type itself, so the compiler tracks it through module boundaries, let aliases, function parameters and return types, record/variant fields, and runtime-constructed values — and emits real JS tagged-template syntax at every call site that uses backtick syntax with such a value.
Sketch
A new abstract type in the standard library — TaggedTemplate.t<'param, 'output> — that the compiler treats specially. Putting it under a stdlib module (the same way Promise.t<'a> lives under Promise) keeps it out of the global namespace:
@module("./sql_client.js")
external sql: TaggedTemplate.t<'a, promise<queryResult>> = "sql"
// Runtime construction becomes expressible:
@module("postgres")
external postgres: string => TaggedTemplate.t<'a, promise<queryResult>> = "default"
let sql = postgres(connectionString)
Because it is a real type, it composes naturally — it can be written in function signatures, returned from factories, stored in records, etc.:
// Cross-module use still emits tagged-template syntax:
await SqlBinding.sql`SELECT * FROM users WHERE id = ${userId}`
// Functions accepting a tag use tagged-template syntax inside:
let findUser = async (sql: TaggedTemplate.t<int, promise<queryResult>>, id) => {
await sql`SELECT * FROM users WHERE id = ${id}`
}
It should also be constructible from pure ReScript — no binding required — by lifting any function of the tag-function shape:
// TaggedTemplate.make: ((array<string>, array<'param>) => 'output) => TaggedTemplate.t<'param, 'output>
type params = I(int) | S(string)
let s = TaggedTemplate.make((strings, parameters) => {
Array.reduceWithIndex(parameters, Array.getUnsafe(strings, 0), (acc, param, i) => {
let suffix = Array.getUnsafe(strings, i + 1)
let p = switch param {
| I(i) => Int.toString(i)
| S(s) => s
}
acc ++ p ++ suffix
})
})
// Used the same way as any other tag — emits real tagged-template syntax at every call site:
let greeting = s`hello ${S("Ada")} you're ${I(36)} years old!`
This closes the gap with Problem 3: a ReScript-authored tag (e.g. one that converts typed parameters before delegating to a JS library) can itself be used as a tag.
Compiler obligations
For a value v whose static type is the tagged-template type:
- Every
v`...` call site emits a real JS tagged template literal — regardless of how many module/function boundaries v crossed.
- No variadic-spread wrapper is generated; the JS value is exported as-is.
- Calling
v as a regular function (v(strings, params)) is either rejected at type-check time or compiles to tagged-template syntax.
- Placeholder and output types are still type-checked end-to-end.
Why this fixes everything above
| Problem |
How the proposal addresses it |
| 1 — runtime-constructed tags |
postgres(...) returns a TaggedTemplate.t<...> and is usable directly. |
| 2a — wrapper-function leakage |
No wrapper emitted; JS value exported as-is. |
| 2b — cross-module degradation |
Type follows the value across modules; call sites emit tagged-template syntax. |
| 2c — first-class / pass-as-parameter use |
Functions declare TaggedTemplate.t<...> parameters; tagged-template syntax preserved. |
| 3 — ReScript-authored wrappers |
TaggedTemplate.make lifts any tag-shaped function into a TaggedTemplate.t<...>. |
Tagged template literals: limitations & proposed alternative
Background
ReScript v11.1 introduced two mechanisms for working with JavaScript tagged template literals:
(array<string>, array<'param>) => 'outputcan be used with backtick syntax. Compiles to a plain function call.@taggedTemplatedecorator onexternal— for binding to JS tag functions. Compiles to real JS tagged-template syntax at call sites, so JS-side tooling that introspects the literal (gql,sql,css, prettier plugins, syntax highlighting) keeps working.Several real-world JS libraries — most notably
postgres— cannot be used through either mechanism. This issue documents the limitations and proposes an alternative.Problem 1 — Cannot bind to a tag function constructed at runtime
postgresdoes not export a tag function. The default export is a factory; the value it returns is the tag:@taggedTemplateonly attaches toexternalbindings, which point at statically-exported names. There is no syntax for "this runtime value is a tagged-template tag", sopostgrescannot be bound directly.Problem 2 — The "re-export from raw JS" workaround silently breaks
The usual workaround is to construct the client in a small JS file under a static export, and bind to that:
2a. Same-module usage works
Compiled JS:
The call site emits a real tagged-template literal. But the compiler also emits a wrapper (
function sql) that does a variadic spread, and that wrapper is what gets exported. Anything importingsqlfrom this module gets the wrapper, not the tag.2b. Cross-module usage falls back to a plain function call
Compiled JS:
This is not a tagged template literal.
postgresenforces tagged-template invocation (it relies onstrings.rawand identity caching of theTemplateStringsArray) and rejects this at runtime.The compiler can only emit real tagged-template syntax when the
@taggedTemplateexternal is in scope as the external itself. Once it crosses a module boundary, the consumer only sees the wrapper.2c. Same problem when the tag flows through any value
Compiled JS:
The moment the tag is passed as a value, every downstream call uses variadic spread.
Problem 3 — Native ReScript tag functions never emit tagged-template syntax
A tag function defined in pure ReScript (no decorator) always compiles to a plain function call:
So a ReScript-authored wrapper around
postgres(e.g. one converting typed parameters before delegating) cannot itself be used as a tag.Summary
@taggedTemplaterequires a static export.postgresand similar).Proposed alternative — make "tagged-template tag" a first-class type
The root cause of every limitation above is that tagged-templateness lives on the binding site rather than the type of the value. The moment the value is exported, imported, aliased, passed as a parameter, or returned from a factory, the compiler loses track of it.
The proposal is to make tagged-templateness a property of the type itself, so the compiler tracks it through module boundaries,
letaliases, function parameters and return types, record/variant fields, and runtime-constructed values — and emits real JS tagged-template syntax at every call site that uses backtick syntax with such a value.Sketch
A new abstract type in the standard library —
TaggedTemplate.t<'param, 'output>— that the compiler treats specially. Putting it under a stdlib module (the same wayPromise.t<'a>lives underPromise) keeps it out of the global namespace:Because it is a real type, it composes naturally — it can be written in function signatures, returned from factories, stored in records, etc.:
It should also be constructible from pure ReScript — no binding required — by lifting any function of the tag-function shape:
This closes the gap with Problem 3: a ReScript-authored tag (e.g. one that converts typed parameters before delegating to a JS library) can itself be used as a tag.
Compiler obligations
For a value
vwhose static type is the tagged-template type:v`...`call site emits a real JS tagged template literal — regardless of how many module/function boundariesvcrossed.vas a regular function (v(strings, params)) is either rejected at type-check time or compiles to tagged-template syntax.Why this fixes everything above
postgres(...)returns aTaggedTemplate.t<...>and is usable directly.TaggedTemplate.t<...>parameters; tagged-template syntax preserved.TaggedTemplate.makelifts any tag-shaped function into aTaggedTemplate.t<...>.