What are template strings really for?

jabher

Vsevolod Rodionov

Posted on September 9, 2020

What are template strings really for?

Since template strings (aka template literals) were released I was feeling that they were kind of unappreciated.

No, of course everyone loved an ability to write like hello${world}, and tricks like this

escape`<html/>`

worked great, but for years I was sure they can do more.

I love one not-so-well-known NoSQL graph database - Neo4j, and I've been building projects with it occasionally.

It was nice, but query syntax was not so great, so I had to write like this:

s.run('MERGE (alice:Person {name : $nameParam, age : $ageParam})', {
    nameParam: 'Alice',
    ageParam: 21
})

and I was literally struggling to think out the name for each variable.

For more context: this is complex query database for research with ton of capabilities and no schema, you cannot simply create an ORM for it, so I had to write raw queries. Simple example of todo list query: "do this task has dependent tasks of infinite deep that are blocked not by other dependencies of this task?".

So, turned out template strings can actually solve this problem and make this simple as hell. I've created a lib for this - cypher-talker and now I'm writing like this, feeling myself really happy for it:

s.run(...t`MERGE (alice:Person {name : ${'Alice'}, age : ${21})`)

I'm planning to simplify it more and write a monkey-patch to write like this:

s.run`MERGE (alice:Person {name : ${'Alice'}, age : ${21})`

but it requires some other driver extensions - like transactions Realms wrapper, but I'll write on it when I'm done with it.

So, what is the trick?

Template strings are expected to be a pure functions. This is important: you are generally not intended to alter something in it. You can, but generally even ESLint will stop you - no-unused-expressions rule prevents you from doing it by default.

Template literal (yes, that's how function to use with template strings called) should have a following signature:

(literals: TemplateStringsArray, ...placeholders: string[]): any

What is cool: typescript fully understands signature of template function, so it will detect an error here:

const t = (literals: TemplateStringsArray, ...placeholders: string[]) => null

t`hello${'world'}${2}`

// and even here!

const t = (literals: TemplateStringsArray, ...placeholders: [string, number, ...string[]]) => null

t`hello${'world'}${true}`

With typescript 4 and its advanced tuples it now works amazing!

If you're curious, what TemplateStringsArray is - it is simply ReadonlyArray<string>, nothing special.

Note that literals size is always 1 time bigger than placeholders length. It will always has a string - even empty, so its reduction may be a bit complicated.

Second magic is that it can return anything

For my lib I needed to produce something spreadable - I mean, iterable. You can return anything: object, array, WeakRef or function. It will simply work.

I know, this looks obvious, but when you really understand what that does mean - you will see a world of possibilities.

Imagine NestJS, but with template string decorators

@Get`docs`
@Redirect`https://docs.nestjs.com`(302)
getDocs(@Query`version` version) {
  if (version && version === '5') {
    return { url: 'https://docs.nestjs.com/v5/' };
  }
}

I think, it looks amazing. Simply remove brackets - and now it looks really declarative. It starts to look not like bunch of function calls all around, but like a some kind of DSL, really.

If you forget how it looks like right now:

@Get('docs')
@Redirect('https://docs.nestjs.com', 302)
getDocs(@Query('version') version) {
  if (version && version === '5') {
    return { url: 'https://docs.nestjs.com/v5/' };
  }
}

Or imagine tests, but with decorator fns

it`tests error with async/await and rejects`(async () => {
  expect.assertions(1);
  await expect(user.getUserName(3)).rejects.toEqual({
    error: 'User with 3 not found.',
  });
});

Looks simple, but what if we will drop some parametrized testing in it?

[2, 3, 5, 7].forEach((value) => {
  it(`should return true for prime number ${value}`, 
() => {
    expect(isPrime(value)).toEqual(true);
  });
});

//vs

it(`should return true for prime number ${[2, 3, 5, 7]}`, 
(value: number) => {
  expect(isPrime(value)).toEqual(true);
});

note: yes, I know that DoneCallback usually should be there, but I'm speaking on general concept, not specific framework

If you think it is not possible with types: it works on nightly TS 4.1. There is issues right now with recursive conditional types in latest TS, but they are fixing it. TS playground

// a bit of functional magic
type car<T> = T extends [infer R, ...any[]] ? R : never
type cdr<T> = T extends [any, ...infer R] ? R : []

type pickType<T> = T extends Array<infer R> ? R : never

type pickFirst<T extends [...unknown[][]]> = T extends [] 
    ? [] 
    : [pickType<car<T>>, ...pickFirst<cdr<T>>]

const it = <T extends [...unknown[][]]>(
    literals: TemplateStringsArray, ...placeholders: T
) => {
    return (fn: (...args: pickFirst<T>) => void) => {

    }
}

it`hello${['world']} ${[true, 5]}`(
(v: string, g: number | boolean) => {
 // test it!
})

Conclusion

I really think that template strings are damn unappreciated. They can bring you DSL you want - and keep the glory of types.

Try to love them more!

💖 💪 🙅 🚩
jabher
Vsevolod Rodionov

Posted on September 9, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related