Writing string.replace in TypeScript

Matt Pocock
Matt Pocock

Since 4.1, TypeScript has had the power to manipulate and transform strings using template literal syntax. Take the example below:

You'll be able to call goToRoute using anything starting with a /. But any other string will be an error.

You can use unions inside template literal types to expand into larger unions:

You can even use infer inside template literals.

Here, ${infer TFirstName} ${infer TLastName} represents any two strings with a space between:

And it instantiates TFirstName and TLastName as type variables which can be used if it matches the string passed in. The ? TLastName returns the last name, meaning you can use GetLastName like so:

What about more advanced use cases? What if we wanted to replace the space in the name with a dash?

Very nice - we just change the result to ${TFirstName}-${TLastName}. Now, our type variables seem a bit misnamed. Let's switch:

  • TFullName to TString
  • TFirstName to TPrefix
  • TLastName to TSuffix

Now it's more generic. But not quite generic enough - let's make this type helper be able to handle replacing any string with another string.

We've swapped out the with TToReplace, and - with TReplacement. This ends up working pretty well:

Except, there are a couple of bugs. For instance, that never looks a bit suspicious. If Replace doesn't find any TToReplace, it returns never:

What is the correct behaviour? We want to just return whatever string got passed in:

The second bug is that it only replaces once. If there's more than one instance of the TToReplace, it ignores the second onwards.

This feels like a tricky bug to fix - until we consider how ${infer TPrefix}${TToReplace}${infer TSuffix} works. In a string like Matt Pocock III, it will infer like so:

  • TPrefix: "Matt"
  • TSuffix: "Pocock III"

This means that the rest of the work needs to be performed on TSuffix. Again, this feels intractable - until we realise that you can call types recursively. This means that we can wrap TSuffix in StringReplace:

Whenever you're doing recursion, you need to make sure you don't end up in an infinite loop. So let's track what StringReplace gets passed:

First, StringReplace<"Matt Pocock III", " ", "-">. This returns Pocock III.

Second, StringReplace<"Pocock III", " ", "-">. This returns III.

Finally, StringReplace<"III", " ", "-">. Since it can't find any instances of " ", it just returns TString (in this case, "III"). We found the end of our recursive loop!

Thanks for following along on this deep dive of conditional types, template literals and infer. If you enjoyed it, you'll love my full course where we go even deeper, and build up all the knowledge with step-by-step exercises.

Matt's signature

Share this article with your friends