All Articles

Writing string.replace in TypeScript

Matt Pocock
Matt PocockMatt is a well-regarded TypeScript expert known for his ability to demystify complex TypeScript concepts.

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

type InternalRoute = `/${string}` 

const goToRoute = (route: InternalRoute) => {} 

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:

type EntityAttributes = `${'post' | 'user'}${'Id' | 'Name'}` 
//   ^? 'postId' | 'userId' | 'postName' | 'userName' 

You can even use infer inside template literals.

type GetLastName<TFullName extends string> = 
  TFullName extends `${infer TFirstName} ${infer TLastName}` ? TLastName : never 

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

Matt Pocock
Jimi Hendrix
Charles Barkley
Emmylou Harris 

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:

type Pocock = GetLastName<'Matt Pocock'> 
//   ^? "Pocock" 

type Hendrix = GetLastName<'Jimi Hendrix'> 
//   ^? "Hendrix" 

type Barkley = GetLastName<'Charles Barkley'> 
//   ^? "Barkley" 

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

type ReplaceSpaceWithDash<TFullName extends string> = 
  TFullName extends `${infer TFirstName} ${infer TLastName}`
    ? `${TFirstName}-${TLastName}`
    : never
 
type Name = ReplaceSpaceWithDash<'Emmylou Harris'>
//   ^? "Emmylou-Harris" 

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
type ReplaceSpaceWithDash<TString extends string> = 
  TString extends `${infer TPrefix} ${infer TSuffix}` 
    ? `${TPrefix}-${TSuffix}` 
    : never 

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.

type Replace< 
  TString extends string, 
  TToReplace extends string, 
  TReplacement extends string, 
> = TString extends `${infer TPrefix}${TToReplace}${infer TSuffix}` 
  ? `${TPrefix}${TReplacement}${TSuffix}` 
  : never 

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

type DashName = Replace<'Matt Pocock', ' ', '-'> 
//   ^? "Matt-Pocock" 

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:

type Result = Replace<'Matt', ' ', '-'> 
//   ^? never 

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

type Replace< 
  TString extends string, 
  TToReplace extends string, 
  TReplacement extends string, 
> = TString extends `${infer TPrefix}${TToReplace}${infer TSuffix}` 
  ? `${TPrefix}${TReplacement}${TSuffix}` 
  : TString 
  
type Result = Replace<'Matt', ' ', '-'> 
//   ^? "Matt" 

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

type DashCaseName = Replace<'Matt Pocock III', ' ', '-'> 
//   ^? "Matt-Pocock III" 

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:

type StringReplace< 
  TString extends string, 
  TToReplace extends string, 
  TReplacement extends string, 
> = TString extends `${infer TPrefix}${TToReplace}${infer TSuffix}`
  ? `${TPrefix}${TReplacement}${StringReplace< 
    TSuffix, 
    TToReplace, 
    TReplacement 
  >}`
  : TString
  
type Result = StringReplace<'Matt Pocock III', ' ', '-'> 
//   ^? "Matt-Pocock-III" 

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

`any` Considered Harmful, Except For These Cases

Discover when it's appropriate to use TypeScript's any type despite its risks. Learn about legitimate cases where any is necessary.

Matt Pocock
Matt Pocock

No, TypeScript Types Don't Exist At Runtime

Learn why TypeScript's types don't exist at runtime. Discover how TypeScript compiles down to JavaScript and how it differs from other strongly-typed languages.

Matt Pocock
Matt Pocock

Deriving vs Decoupling: When NOT To Be A TypeScript Wizard

In this book teaser, we discuss deriving vs decoupling your types: when building relationships between your types or segregating them makes sense.

Matt Pocock
Matt Pocock

NoInfer: TypeScript 5.4's New Utility Type

Learn how TypeScript's new utility type, NoInfer, can improve inference behavior by controlling where types are inferred in generic functions.

Matt Pocock
Matt Pocock