Announcing: A Free Book, A New Course, A Huge Price Cut...
It's a massive ship day. We're launching a free TypeScript book, new course, giveaway, price cut, and sale.
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.
Share this article with your friends
It's a massive ship day. We're launching a free TypeScript book, new course, giveaway, price cut, and sale.
Learn why the order you specify object properties in TypeScript matters and how it can affect type inference in your functions.
Learn how to use corepack
to configure package managers in Node.js projects, ensuring you always use the correct one.
Learn how to strongly type process.env in TypeScript by either augmenting global type or validating it at runtime with t3-env.
Discover when it's appropriate to use TypeScript's any
type despite its risks. Learn about legitimate cases where any
is necessary.
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.