Advanced Template Literals Techniques in TypeScript

ยท

5 min read

We recently talked about template literals and how you can use them to do different types of checks as well as string manipulation. Today we are going to take it one step further and expand our knowledge of template literals and combine them with other TypeScript fundamentals to do some really cool things.

The last post laid the foundation, setting the stage for today's challenge. If you haven't taken a look, it's a valuable read that will come in handy for what's up next!

Let's jump into the challenge.

Day fourteen - Dec 14

I strongly encourage you to try the first challenge on your own before reading the solution here.

Challenge: Naughty List Decipher

The challenge

  • [early on the morning of Thursday December 14th, Santa stumbles into office greeted by Bernard, the head elf..]*

[Bernard] YOU'RE A MESS. Were you out partying.. on a WEDNESDAY?? AGAIN??!!!

[Santa] It seems as such. Some investors were in town so we went over to the Mistletoe Lounge and things got a little out of hand.

[Bernard] I oughta report you to HR. Seriously. This is getting out of control.

[Santa] We're like a family here; no need for formal HR processes!

[Bernard] Where's the list for today's naughty kids? We're behind on coal lump production.

[Santa] Umm.

[Bernard] You're joking. Tell me you're joking. You lost the list again?

[Santa] Well, not lost per se.

[Bernard] Then where is it?

[Santa] I have it.. but I only scribbled down the names real quick with slashes in between them.

Covering for Santa, again.

Looks like we're gonna need to pick up the slack for Santa yet again. He's got a list like "melkey/prime/theo/trash" and we need to turn it into a union of strings "melkey" | "prime" | "theo" | "trash".

Let's get this done before the rest of the elves find out.

The code to complete

type DecipherNaughtyList = unknown;

The tests

import { Expect, Equal } from 'type-testing';

type test_0_actual = DecipherNaughtyList<'timmy/jimmy'>;
type test_0_expected = 'jimmy' | 'timmy';
type test_0 = Expect<Equal<test_0_expected, test_0_actual>>;

type test_1_actual = DecipherNaughtyList<'elliot'>;
type test_1_expected = 'elliot';
type test_1 = Expect<Equal<test_1_expected, test_1_actual>>;

type test_2_actual = DecipherNaughtyList<'melkey/prime/theo/trash'>;
type test_2_expected = 'melkey' | 'prime' | 'theo' | 'trash';
type test_2 = Expect<Equal<test_2_expected, test_2_actual>>;

The solution

type DecipherNaughtyList<T extends string> = 
  T extends `${infer Head}/${infer Tail}`
    ? Head | DecipherNaughtyList<Tail>
    : T;

Right off the bat, you may notice concepts that we have covered multiple times in this series and a couple of new ones as well. Things like generic and conditional types, and the infer and extends keywords are topics that have helped us in the past to solve some challenges.

Let's start breaking down the solution:

Type Definition:

type DecipherNaughtyList<T extends string>

We are starting by defining a generic type parameter T that extends string, meaning that our input should always be of type string.

Conditional Type:

T extends `${infer Head}/${infer Tail}` 
  ? Head | DecipherNaughtyList<Tail> 
  : T;

DecipherNaughtyList is equal to the result of the above conditional. Which checks if the input string T can be split into two parts separated by a '/' .
extends is making sure that T is a string in the form of "something/somethingElse" .

If T can be split, it takes the first part (Head) and recursively calls DecipherNaughtyList In the second part (Tail). If not, it simply returns the original string T.

And yes, my friends... we are using our beloved recursion to solve another challenge. Fun times, right?

Recursive Splitting:

`${infer Head}/${infer Tail}`

The above statement is a template literal that represents the part where the recursive splitting happens. This will split the string at the first occurrence of '/'.

Head represents the part before '/', and Tail represents the part after it. But we can use any other names like Prefix and Rest .

The infer keyword is needed to allow TypeScript to infer the types of Head and Tail based on the structure of the string, in other words, making sure we are always using a string in the template literal.

This way of using template literals took me a bit to "fully" learn and grasp, so just in case, I want to reiterate how this is working.
${infer Head}/${infer Tail} is like saying, "Hey TypeScript, if my string looks like 'something/somethingElse', figure out and remember what 'something' is and call it Head, and what 'somethingElse' is and call it Tail for me."

At the end, the type combines Head with the result of the recursive call on Tail, resulting in a union type.

Head | DecipherNaughtyList<Tail>

Base Case:

T extends `${infer Head}/${infer Tail}` ? ... : T;

Finally, if the string cannot be split further meaning that there are no more '/', it returns the original string T. This is the base case for the recursion.

Breaking it down with an example

Now, let's walk through how our solution works using the following example:

DecipherNaughtyList<'melkey/prime/theo/trash'>;
  1. Initial Call: The input is 'melkey/prime/theo/trash'. The type checks if it can be split into ${infer Head}/${infer Tail}.

  2. First Split: It splits into Head = 'melkey' and Tail = 'prime/theo/trash'. The type then becomes 'melkey' | DecipherNaughtyList<'prime/theo/trash'>.

  3. Recursive Call: Now, the type calls itself with the input 'prime/theo/trash'. This follows the same logic:

    • It splits into Head = 'prime' and Tail = 'theo/trash'.

    • The type becomes 'prime' | DecipherNaughtyList<'theo/trash'>.

  4. Second Recursive Call: Continuing the recursion, it now calls itself with the input 'theo/trash':

    • It splits into Head = 'theo' and Tail = 'trash'.

    • The type becomes 'theo' | DecipherNaughtyList<'trash'>.

  5. Base Case: Now, the recursion stops because the input 'trash' cannot be split further. The type returns the original string 'trash'.

  6. Building the Union: While the recursion is happening, the type combines the results of each recursive call. The union is constructed by taking the Head at each step:

    • 'melkey' | ('prime' | ('theo' | 'trash'))
  7. Final Union: The union simplifies to 'melkey' | 'prime' | 'theo' | 'trash'.

Today we really covered a lot of ground, I hope you should be feeling like a template literal master now.

As we wrap up another day of TypeScript exploration, we're also wrapping up presents โ€“ getting closer to Christmas folks!

If you like this content consider checking out what I post on Twitter/X ๐Ÿฆ

ย