Author avatar
Barista Posted on February 26, 2026

Tagged template strings in Javascript

Have you ever seen code like this?

const email = request.body.someValue;
const result = await prisma.$queryRaw`SELECT * FROM User WHERE email = ${email}`;

At first glance, this may look like a code vulnerable to SQL Injection due to the string interpolation with user input into a raw SQL query. Well, this code is actually safe. How so?

Not just interpolation

If you are familiar with (somewhat) modern Javascript, you probably know how to interpolate strings in it:

`Hello ${username}`. Of course, if you use that for a SQL Raw Query, you would end up with SQL Injection, but if you take a closer look into the first code snippet, you will notice that there's a "tag" before the interpolation prisma.$queryRaw`SELECT * FROM User WHERE email = ${email}`;. The prisma.$queryRaw is a tag function and they have super powers.

Tag functions

Tag functions, or tagged templates, allow you to extend string templates as you wish. Meaning, you can pre-process and manipulate the literal string and the template values. It doesn't even need to return a string.

Like the Prisma example, the values aren't simply interpolated. Instead, Prisma executes a safe query, as long as you use it correctly.

Writing your own tag function

To show how it works in practice, let's write a tag function that will transform all the values to upper case!

// For: upperCaseIt`Hello ${name}, how are you doing ${time}?`
// strings: ["Hello ", ", how are you doing ", "?"]
// values:  ["John", "today"]
function upperCaseIt(
  strings: TemplateStringsArray,
  ...values: string[]
): string {
  const builder = new Array();
  let valueIdx = 0;

  for (const str of strings) {
    builder.push(str);
    if (valueIdx < values.length) {
      const value = values[valueIdx++];
      builder.push(value.toUpperCase());
    }
  }

  return builder.join("");
}


const name = "John";
const time = "today";
console.log(upperCaseIt`Hello ${name}, how are you doing ${time}?`);
// Hello JOHN, how are you doing TODAY?

Even though this is a simple example, it's possible to make great things with it, like we saw with Prisma.

Another practical example: redacting sensitive data in logs. In production, you don't want to leak user data into your logs, and tagged string templates can help.

function redact(
  strings: TemplateStringsArray,
  ...values: unknown[]
): string {
  const builder = new Array();
  let valueIdx = 0;
  const isDev = process.env.NODE_ENV === "dev";

  for (const str of strings) {
    builder.push(str);
    if (valueIdx < values.length) {
      const value = values[valueIdx++];
      builder.push(isDev ? value : "[REDACTED]");
    }
  }

  return builder.join("");
}

const email = "[email protected]";

console.log(redact`User ${email} failed to log in`);
// dev:  User [email protected] failed to log in
// prod: User [REDACTED] failed to log in

While this is cool, there is an issue. All values are redacted, but we can fix it by adding a wrapper class and a helper function!

class Redactable<T> {
  constructor(public value: T) {}
}

function sensitive<T>(value: T): Redactable<T> {
  return new Redactable(value);
}

function redact(strings: TemplateStringsArray, ...values: unknown[]): string {
  const isDev = process.env.NODE_ENV === "dev";
  const builder = new Array();
  let valueIdx = 0;

  for (const str of strings) {
    builder.push(str);
    if (valueIdx < values.length) {
      const rawValue = values[valueIdx++];
      const isRedactable = rawValue instanceof Redactable;
      const value = isRedactable ? rawValue.value : rawValue;
      const shouldRedact = !isDev && isRedactable;
      builder.push(shouldRedact ? "[REDACTED]" : value);
    }
  }

  return builder.join("");
}

const email = "[email protected]";
const errorCount = 3;

console.log(
  redact`User ${sensitive(email)} has ${errorCount} failed login attempts`,
);
// dev:  User [email protected] has 3 failed login attempts
// prod: User [REDACTED] has 3 failed login attempts

Amazing!!

Of course, this is still just a simplified example. For a production-ready implementation, you might want to use Symbols because instanceof with multiple realms, modules and global contexts might not work as expected (as it's customary for Javascript, expect the unexpected). Also, handle stringification of types so you don't end up with [object Object].

You can, but should you?

This is a cool feature, but not known by many. Plus, doing too much implicitly can be extremely confusing.

Once I had to fix some SAST warnings on a project I worked on, and one of the most common ones were Server-Side Request Forgery (SSRF) on HTTP requests with the path built by interpolating strings with user input. Like:

fetch(`https://example.com/v1/users/${userId}`)

In the case, while not really SSRF (I guess path manipulation would more correct), if an attacker controls the value for userId, they can manipulate the request path itself, for example, if they pass 2/credit-cards the final URL would be https://example.com/v1/users/2/credit-cards! So, a request that was supposed to be to get a user by id, now ends up reaching another API.

So I did what any sane developer would do: I wrote an overcomplicated solution: a tagged function that would encodeURIComponent the values. It didn't even make it to a code review, because I knew that first, the SAST would not be smart enough to realize "it is safe", and second, most of the reviewers would have no idea what that did.

So I did what any sane developer would do: I used a regex. Again, the reviewers had no idea what it did, but at least they would be more likely to approve it.

In the end, once more, like any sane developer, I ended up turning that into a Javascript library!

Craft URL

Introducing Craft URL. It's a tagged function that allows you to use string interpolations more safely! Source on Github.

import { urlify, raw } from "craft-url";

// Basic usage
const username = "john doe";
const encodedUri = urlify`/users/${username}`;
console.log(encodedUri); // Output: "/users/john%20doe"

// Query parameters
const filter = "active&new";
const encodedUriWithQuery = urlify`/users?filter=${filter}`;
console.log(encodedUriWithQuery); // Output: "/users?filter=active%26new"

// Using raw values
const baseUrl = "https://api.example.com";
const encodedUriWithRaw = urlify`${raw(baseUrl)}/users/${username}`;
console.log(encodedUriWithRaw); // Output: "https://api.example.com/users/john%20doe"

// Even for database connection strings
const host = "localhost";
const user = "user";
const password = "p@ass/word";
const connStr = urlify`postgresql://${user}:${password}@${host}`;
console.log(connStr); // Output: "postgresql://user:p%40ass%2Fword@localhost"

Article licensed under CC BY-NC-SA 4.0 Code licensed under MIT
Blog powered by SvelteKit, 4 hours of sleep and