Skip to content

Tagged template literals: limitations & proposed alternative #8415

@JonoPrest

Description

@JonoPrest

Tagged template literals: limitations & proposed alternative

Background

ReScript v11.1 introduced two mechanisms for working with JavaScript tagged template literals:

  1. 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.
  2. @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:

  1. Every v`...` call site emits a real JS tagged template literal — regardless of how many module/function boundaries v crossed.
  2. No variadic-spread wrapper is generated; the JS value is exported as-is.
  3. Calling v as a regular function (v(strings, params)) is either rejected at type-check time or compiles to tagged-template syntax.
  4. 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<...>.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions