JavaScript tagged templates

If you’ve seen code such as the one below (taken from https://www.styled-components.com/docs/basics)

styled.section`
  padding: 4em;
  background: papayawhip;
`

You might be interested in the styled.section code. This is a function and uses template literals as input via template literal syntax. In this usage its known as a tagged template or tagged template literals.

Let’s create our own function to show how this works.

function tt(literals: any, …substitutions: any) {
console.log(literals);
console.log(substitutions);
}

Note: I’m using TypeScript, hence the use of the any keyword, but just remove this for JavaScript code.

If we now run the following code

const name1 = "Scooby";
const name2 = "Doo";

tt`Hello ${name1} World ${name2}`

The following will be logged to the console

[ 'Hello ', ' World ', '' ]
[ 'Scooby', 'Doo' ]

The first values are an array of the literals passed to our function, the second are the substitutions.

Literals will always be an array of substitutions.length + 1 in length. Hence in the example above the literals contains an empty string item at the end to ensure this is the case.

Note: The last item in the literals array is an empty string but ofcourse if we had a string after the ${name2} then this would be the last item, hence to combine these two arrays into a resultant string would require us to ensure we merge all items.

We can therefore combine our two arrays to form a single result using a simple loop, like this


function tt(literals: any, ...substitutions: any) {
  let s = "";

  for (let i = 0; i < substitutions.length; i++) {
    s += literals[i] + substitutions[i];
  }

  return s + literals[literals.length - 1];
}

In the above we’re simply returning a string representing the merge of the two arrays. Remember literals.length will be substitutions.length + 1, hence we simply append that after looping through the smaller of the arrays.

Ofcourse this it not really that useful, if all we wanted to do was return a string we could just create a template literal. Let’s look at a couple of ways of enhancing the functionality.

The first obvious requirement is that we should be able to pass functions into the templates. For example if we have something like this

const t = tt`
 firstName: ${name1};
 lastName: ${name2};
 preferred: ${choice => (choice ? name1 : name2)};
 `;

The choice value needs to be supplied by the calling code and in this example code there’s no easy was to pass this data into t. So first off we need to wrap the tt function within another function and return it, like this

 
function tt(literals: any, ...substitutions: any) {
  return function(options: any) {
    let s = "";

    for (let i = 0; i < substitutions.length; i++) {
      s += literals[i];
      s += typeof substitutions[i] === "function"
        ? substitutions[i](options)
        : substitutions[i];
    }
    return s + literals[literals.length - 1];
  };
}

In the above we’ve also added changes to the original tt function to detect functions within the substitutions. If a function is found whilst looping then it’s invoked by passing in the supplied options.

This implementation then returns a function which, when invoked by passing in some value (in this case named options), will loop through the literals and substitutions and invoking any functions by forwarding the supplied options.

Hence we can call the new tt method like this, for example

t({choice: true});

This would return a string and would return

firstName: Scooby;
lastName: Doo;
preferred: Scooby;

So now for the next enhancement, let’s instead of returning a string, return an object – all we need to do is split on semi-colons to get key/value items where the key will become the object’s property and the value obviously the value stored within the property.

We’ll make a slight change to the code above to this

function tt(literals: any, ...substitutions: any) {
  return function(options: any) {
    let s = "";

    for (let i = 0; i < substitutions.length; i++) {
      s += literals[i];
      s += typeof substitutions[i] === "function"
        ? substitutions[i](options)
        : substitutions[i];
    }

    return toObject(s + literals[literals.length - 1]);
  };
}

The toObject function has been introduced and it’s purpose is to…

  • Take a string which is semi-colon deliminated for each key/value pair
  • Extract each key/value pair which should be deliminated with colons
  • For each entry we will create a property with the name taken from left of the colon on an object and the value right of the colon will be assigned to the property as a value

Here’s the code for toObject

const toObject = (value: any): any =>
  value
    .split(";")
    .map(entry => {
        const e = entry.split(":");
        if(e.length == 2) {
            const key = e[0].trim();
            const value = e[1].trim();
            return [key, value];
        }
        return undefined;
    })
    .filter(entry => entry != undefined)
    .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1]}), {});

This is not a complete solution as we’re not ensuring validity of the key as a property name. For example you’ll have noticed in styled.component or even React’s styles, that hyphen keys, i.e. background-color or similar would be converted or expected to be backgroundColor. So a simply change would be to convert line 7 to this following

const key = ensureValid(e[0].trim());

and now we introduce a new function to handle all our checks, for now we’ll just ensure the hyphen’s or dot’s are removed and replaced by camelCase

const ensureValid = (key: string): string => 
 key.replace(/[-.]+/g, c => c.length > 0 ? c.substr(1).toUpperCase() : '');

Obviously this function is quite limited, but you get the idea. It can then be used in the toObject function, i.e.

// change
const key = e[0].trim();
// to
const key = ensureValid(e[0].trim());

Taking things a little further

The code below is based upon what was discussed in this post, but extended a little, to start with here’s a more complete implementation of the above code

const ensureValid = (key: string): string => 
    key.replace( /[-.]+([a-z]|[0-9])|[-.]$/ig, (_match, character, pos) => {
        if(pos == 0) {
            return character.toLowerCase();
        }
        else if(character == null) {
            return '';
        }

        return character.toUpperCase();
    });

const toObject = (value: any): any =>
  value
    .split(";")
    .map(entry => {
        const e = entry.split(":");
        if(e.length == 2) {
            const key = ensureValid(e[0].trim());
            const value = e[1].trim();
            return [key, value];
        }
        return undefined;
    })
    .filter(entry => entry != undefined)
    .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1]}), {});

function tt3(literals: any, ...substitutions: any) {
    return function(options: any) {
        let s = "";

        for (let i = 0; i < substitutions.length; i++) {
            s += literals[i];
            s += typeof substitutions[i] === "function"
                ? substitutions[i](options)
                : substitutions[i];
        }

        return toObject(s + literals[literals.length - 1]);
    };
}

const name1 = "Scooby";
const name2 = "Doo";

const t = tt3`
 -First-6name-: ${name1};
 last-Name: ${name2};
 preferred: ${options => (options.choice ? name1 : name2)};
 `;

console.log(t({choice: true}));

Now let’s have a bit of fun and refactor things to allow us to extract our object from alternate data representations. We’ll create an ini style way to define our objects

const camelCase = (key: string): string => 
    key.replace( /[-.]+([a-z]|[0-9])|[-.]$/ig, (_match, character, pos) => {
        if(pos == 0) {
            return character.toLowerCase();
        }
        else if(character == null) {
            return '';
        }

        return character.toUpperCase();
    });

type splitterFunc = (value: any) => [{key: any; value: any}|undefined];

const standardSplitter = (value: any):  [{key: any; value: any}|undefined] =>
    value
        .split(";")
        .map(entry => {
            const e = entry.split(":");
            if(e.length == 2) {
                const key = camelCase(e[0].trim());
                const value = e[1].trim();
                return [key, value];
            }
            return undefined;
        });

const iniSplitter = (value: any):  [{key: any; value: any}|undefined] =>
        value
            .split("\n")
            .map(entry => {
                const e = entry.split("=");
                if(e.length == 2) {
                    const key = camelCase(e[0].trim());
                    const value = e[1].trim();
                    return [key, value];
                }
                return undefined;
            });
    

const toObject = (value: any, splitter: splitterFunc = standardSplitter): any =>
    splitter(value)
      .filter(entry => entry != undefined)    
      .reduce((obj, entry) => ({ ...obj, [entry![0]]: entry![1]}), {});


function tt3(literals: any, ...substitutions: any) {
    return function(options: any, splitter: splitterFunc = standardSplitter) {
        let s = "";

        for (let i = 0; i < substitutions.length; i++) {
            s += literals[i];
            s += typeof substitutions[i] === "function"
                ? substitutions[i](options)
                : substitutions[i];
        }

        return toObject(s + literals[literals.length - 1], splitter);
    };
}

const name1 = "Scooby";
const name2 = "Doo";
    
const t = tt3`
 -First-6name-: ${name1};
 last-Name: ${name2};
 preferred: ${options => (options.choice ? name1 : name2)};
 `;

 const t1 = tt3`
 -First-6name- = ${name1}
 last-Name = ${name2}
 preferred = ${options => (options.choice ? name1 : name2)}
 `;

console.log(t1({choice: true}, iniSplitter));